// settings: per-sandbox agent settings (.intentic/config/settings.json), plus the checks a repository declares for // itself, which those settings adopt. import { STATE_DIR } from "@intentic/constants"; import { z } from "zod"; import { claudeExperimentSettings, iqSearchSettings } from "./settings-experiments.js"; import { CommandJudgeModeSchema, ProjectInstallModeSchema } from "../policy/safety-policy.js"; import { ModelRoleSchema } from "../models/model-roles.js"; import { MODEL_SETUP_ID, ModelSetupSchema } from "../models/model-setups.js"; import { AdmissionPolicySchema, AdmissionRuleSchema } from "./command-admission.js"; import { KeepWarmSettingsSchema } from "./keep-warm.js"; import { LimitHandoffSchema } from "./providers/handoff.js"; import { SessionsPlacementSchema } from "./offload.js"; import { LimitPolicySchema, RetryPolicySchema, RoomPolicySchema } from "./turn-break.js"; import { ZoneSchema } from "../time/zone.js"; import { VoiceSettingsSchema } from "./voice.js"; import { AiNoticeSchema, AuditSettingsSchema, RetentionSettingsSchema, HeldCheckoutDaysSchema, AgentRetentionDaysSchema, } from "./data-protection.js"; // Which prompt base the agent runs before this turn composes anything on top: Intentic's own (default), Claude Code's // preset, or the owner's text. Declared out here since both the daemon and the browser branch on it. // Kept in the daemon's auth root, never in SandboxSettings: an agent cannot edit its execution boundary. export const AgentDomainSchema = z.enum(["root", "unprivileged"]); export const AgentDomainPolicySchema = z.object({ agentDomain: AgentDomainSchema.default("root") }); export type AgentDomainPolicy = z.infer; export const SystemPromptModeSchema = z.enum(["intentic", "claude", "custom"]); export type SystemPromptMode = z.infer; // Excludes "custom": there is nothing to fetch, it's whatever the owner already typed into the settings field. export const BuiltinPromptSchema = z.object({ base: z.enum(["intentic", "claude"]) }); // Longest an owner's own system prompt may be, the sandbox's or a persona's; named so an editor can refuse a save the // daemon would refuse, rather than copy the number. export const SYSTEM_PROMPT_MAX = 20_000; // Longest the guidance the New chat routing job reads may be (`autoModelGuidance` below). export const AUTO_MODEL_GUIDANCE_MAX = 2000; // Rules: "at this moment, if this is true, do this". The owner's rules decide (land, hold, version); every command a // moment runs is a repository's own check (`/.intentic/checks.json`), compiled into this same table by the daemon, // so no command lives in settings. Nothing verifies after a land or on the way to a push, and nothing here holds // anything: what a check finds inside a turn is said back to the model, and CI checks what the owner pushes // (schemas/ci.ts). export const RuleMomentSchema = z.enum([ // A command here runs on the just-written file (`{file}` is its path); the cheapest moment to catch a defect. "file.edited", // An isolated turn about to stop: a repository's own `turn` check runs here once, on what the turn changed, and what // it finds is said back to the model, which may fix it or say why not. It holds and refuses nothing. Only what a // repository declares stands here; an owner's rule left here from before is inert, and no save stands a new one. "turn.ending", // An agent's turn is over and its delta is sitting on its branch. A rule here decides whether it lands. "agent.finished", // An agent's delta has just reached the main tree. A rule here decides what becomes of it there. "agent.landed", ]); export type RuleMoment = z.infer; // command: a repository's check; its exit code is the verdict. // verdict: allows or holds what is about to happen. // builtin: a daemon behaviour that reads a record only the daemon keeps. // verify-ui-edits: retired with `turn.ending`, read only so older settings parse; whether a turn looked at the surfaces it // changed is recorded on its card instead (AgentSummary.proof), never asked of the model. // version-landed: commits what a land brought, in the main tree, under the subject drafted for it, and the tree's own // remainder before the next isolated turn starts; the only way a person who never commits keeps every agent seeing // the latest tree, since worktrees are cut from HEAD. export const RuleBuiltinSchema = z.enum(["verify-ui-edits", "version-landed"]); export type RuleBuiltin = z.infer; export const RuleActionSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("command"), command: z.string().max(500), // Past this, the process group is killed and the run is `failed`, never a silent pass. timeoutMs: z.number().min(60_000).max(3_600_000).default(900_000), }), z.object({ kind: z.literal("verdict"), verdict: z.enum(["allow", "hold"]) }), z.object({ kind: z.literal("builtin"), name: RuleBuiltinSchema }), ]); export type RuleAction = z.infer; // `checks-failed` was a clean turn whose `turn.ending` check failed. Nothing produces it now: a `turn` check's findings // are said back to the model and never decide whether its work lands. It stays so older rules naming it still read. export const RuleOutcomeSchema = z.enum(["clean", "error", "conflict", "checks-failed"]); export type RuleOutcome = z.infer; // Every key absent means the rule always matches. export const RuleConditionSchema = z.object({ // A workspace repo id, or "root". Absent ⇒ any. repo: z.string().min(1).optional(), // Globs the change has to touch for the rule to fire. Absent/empty ⇒ any. paths: z.array(z.string().min(1)).max(20).optional(), // How the turn ended. Absent/empty ⇒ any. outcome: z.array(RuleOutcomeSchema).optional(), // The fraction of occasions a rule fires on, at a moment that draws one; absent ⇒ every occasion. No moment draws // one any more (`turn.ending` did, before it held only the checks a repository declares), so a sampled rule fires // every time. sample: z.number().gt(0).lt(1).optional(), }); export type RuleCondition = z.infer; // `id` is stable and owner-visible, so a rename doesn't orphan the firing history. Which actions fit which moment is // validated here, not left to the consumer — the alternative is a rule that saves cleanly and silently does nothing. const MOMENT_ACTIONS: Record = { "file.edited": ["command"], "turn.ending": ["builtin", "command"], "agent.finished": ["verdict"], "agent.landed": ["builtin"], }; // A built-in reads a record only one moment keeps, so it stands at that moment alone. const MOMENT_BUILTINS: Partial> = { "turn.ending": ["verify-ui-edits"], "agent.landed": ["version-landed"], }; export const RuleSchema = z .object({ id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), label: z.string().min(1).max(80), moment: RuleMomentSchema, when: RuleConditionSchema.optional(), action: RuleActionSchema, enabled: z.boolean().default(true), }) .refine((rule) => MOMENT_ACTIONS[rule.moment].includes(rule.action.kind), { message: "that action cannot stand at that moment", path: ["action"], }) .refine((rule) => rule.action.kind !== "builtin" || (MOMENT_BUILTINS[rule.moment] ?? []).includes(rule.action.name), { message: "that built-in cannot stand at that moment", path: ["action"], }); export type Rule = z.infer; // A rule the owner's settings no longer take: one at `turn.ending`, where only a repository's own checks stand, or one // with the retired `verify-ui-edits` built-in. Read, so an older file parses, but never written again. export const isRetiredMomentRule = (rule: Pick): boolean => rule.moment === "turn.ending" || (rule.action.kind === "builtin" && rule.action.name === "verify-ui-edits"); // Kept out of the settings object on purpose: a firing is not an edit, and writing config on every push would make // every run a settings save. export const RuleFiringsSchema = z.record(z.string(), z.number()); export type RuleFirings = z.infer; // Where a skill came from, the fact that decides everything else about its row. // builtin: this image ships it. // own: the owner wrote it (.intentic/config/skills/); on while its loaded copy exists; only these are editable here. // capability: something connected brought it (a CLI tool, a machine, a browser account, a VPN). // extension: an installed extension ships it inside its checkout. // plugin: a plugin capability cloned a repo that holds it. // persona: one card's own kit carries it; only turns wearing that card see it. // dropped: sitting in the loaded folder by hand or by the agent, claimed by nothing else. // Not a capability kind: a capability can be broken and wants a status light, a skill either exists or it doesn't. export const SkillOriginSchema = z.enum(["builtin", "own", "capability", "extension", "plugin", "persona", "dropped"]); export type SkillOrigin = z.infer; // Same slug shape the SDK's loader accepts, checked here so a bad name is a refused save, not a skill that silently // never loads. export const SkillNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "a skill name is lowercase letters, digits and dashes"); export const SkillSummarySchema = z.object({ // Qualified as `::` for anything not `own`/`builtin`. id: z .string() .describe( "Its handle, which reading and deleting take. A skill of your own is simply its name; one belonging to something else is qualified, because two packages may each ship a review.", ), name: z.string().describe("Its name."), description: z .string() .describe( "What it is for, which is the line the agent reads to decide whether to reach for it. Empty when the skill declares none, which is worth showing as the blank it is: a skill with no description is rarely picked.", ), origin: SkillOriginSchema.describe("Where it came from."), // e.g. an extension's title, a plugin capability's id, a setting's name. owner: z.string().optional().describe("Who ships it, as the row would name them."), enabled: z.boolean().describe("Whether the agent can reach it."), // True for a baked tool (switched through the settings `skills` list) and the owner's own (through `skills.switch`). switchable: z .boolean() .describe( "Whether this surface can switch it. Everything else is on because its extension or its plugin is, and a switch here that silently did nothing would be worse than none, so the row names its owner instead.", ), editable: z .boolean() .describe( "Whether it can be rewritten here. Your own only: editing somebody else's in place would be undone the next time the thing that ships it catches up.", ), // Wider than `editable` by one case: a dropped skill isn't the owner's to edit, but is theirs to remove, with no // other way to short of the file tree. removable: z.boolean(), }); export type SkillSummary = z.infer; export const SkillsListSchema = z.array(SkillSummarySchema); // Its own route, not a field on the summary: bodies run to thousands of words, too costly to include in a list of // one-line rows. export const SkillBodySchema = z.object({ id: z.string().describe("The skill's id, which can carry the owner it came from."), name: z.string().describe("Its name."), // Everything after the frontmatter. body: z.string().describe("The instructions themselves, as written."), }); export type SkillBody = z.infer; export const SkillIdSchema = z.object({ id: z .string() .min(1) .describe( "Which skill. It travels in the query rather than the address, because an id can name the owner it came from and that will not fit in a path.", ), }); // Three fields because a skill is three things: name, when to reach for it, what to do. The daemon assembles // frontmatter from the first two, so a saved skill can never be one the loader skips. export const SkillDraftSchema = z.object({ name: SkillNameSchema.describe("What to call it. Saving over an existing name rewrites it, which is also how one is renamed."), description: z.string().min(1).max(1024).describe("What it is for, which is what the agent reads to decide whether to reach for it."), body: z.string().min(1).describe("The skill itself."), }); export type SkillDraft = z.infer; export const SkillRemoveSchema = z.object({ name: SkillNameSchema.describe("Which skill to delete. The stored text and the agent's copy go together, so nothing is left half done."), }); export const SkillSwitchSchema = z.object({ name: SkillNameSchema.describe("Which skill of your own to switch."), on: z.boolean().describe("On writes the agent's copy from the stored text; off removes that copy and keeps the text."), }); export type SkillSwitch = z.infer; // Opt-in settings the /settings routes edit and streamAgent reads, defaulting off so each can be A/B tested (`skills` // defaults on for the baked tools worth having, since a skill file is the only thing that tells the agent a baked // binary exists). Every default lives in the schema, so an older settings file still parses and keeps the owner's other // picks rather than failing whole. A setting removed or reshaped here gets its conversion in settings-history.ts, which // also names every retired setting, none of whose names may come back. // keep-warm's knobs live with its clock (keep-warm.ts); re-exported, as every settings reader imports them from here. export { type KeepWarmSettings, KeepWarmSettingsSchema } from "./keep-warm.js"; // Who the editor's screens are written for: `developer` reads git's own words (branch, land, commit), `maker` plain ones // (draft, accept, save) over the same mechanisms. The editor's vocabulary, never anything an agent does. A person's own // answer, kept by the sandbox per member rather than in each browser, so the desktop app and a browser tab name one // button one way while two people on one sandbox can still read it in their own words. export const AudienceSchema = z.enum(["developer", "maker"]); export type Audience = z.infer; // The caller's own answer as the sandbox keeps it; absent until they have given one here. export const MemberAudienceSchema = z.object({ audience: AudienceSchema.optional().describe("developer for git's own words, maker for plain ones. Absent until this person has chosen here."), }); export type MemberAudience = z.infer; export const SandboxSettingsSchema = z.object({ // The zone every WALL-CLOCK RULE in this sandbox is meant in — an automation's cron, and anything else that says // "at 09:00" instead of naming an instant. It is not a display preference: nothing formats through it, and an // instant on screen is still drawn in the reader's own clock. // Empty means nobody has said, and the container's clock (UTC) answers. That is harmless for a sandbox where // nothing is scheduled and wrong the moment something is, which is why the editor offers its own zone on first // load and the automations screen names this one beside every schedule. // An IANA id rather than an offset, because an offset cannot express the summer-time rule that moves "09:00" twice // a year, and a chore set in March should still be right in November. timezone: z .union([z.literal(""), ZoneSchema]) .default("") .describe( "Which clock this sandbox's schedules are set by, as a zone name like Europe/Warsaw. Automations that repeat on a clock fire by this, not by the machine's own time. Leave it empty and they fire by UTC, which is almost certainly not what you meant when you typed a time.", ), skills: z.array(z.string()).default(["lsp", "fileq"]).describe("Which built-in tools are switched on. A skill of your own is not listed here: it is on while the agent's copy of it exists."), // One half of the reading a new chat gets between send and its first turn (chat-router.ts): with this on, the // message and one line per card are put to the same model that chooses what the chat runs on, in the same call. // Never asked of a draft: only a sent message is a finished one. Attended chats only: routing onto a card would // grant an unwatched wake accounts nobody named for it. personaRouting: z .boolean() .default(true) .describe( "Whether a new chat is matched to one of your personas from its first message. It is read once the message is sent, in the same single call that chooses what the chat runs on (the New chat routing job under Models), and the chat says in its own transcript which persona it landed on. Never applies to unwatched runs, which name their persona themselves.", ), // The `show_page`/`ask_page` tools and the guidance that names them (agent/pages/page-tools.ts); off, a turn is // offered neither and draws no page, which costs it nothing. chatPages: z .boolean() .default(true) .describe( "Let the agent answer with a page drawn right in the chat: a chart, a table, a diagram, a mock-up, or a small form you answer it on. Off, the agent is not offered the tools at all, so it spends nothing on them.", ), // The daemon's shadow keeper (derived/shadow-keeper.ts) and the ripgrep config agents' shells get with it. On by // default: it renders without asking any model (OCR excepted, on this machine), one low-priority process at a time. documentSearch: z .boolean() .default(true) .describe( "Keep the text of every document in the workspace (pdf, Word, OpenDocument, spreadsheets, presentations, epub, and scans where OCR is installed) ready to search. In the background, one low-priority process at a time, each document is rendered to text when it appears or changes, so `rg` and iq find what documents say under their own paths, cited by page. Off, nothing renders until someone opens a document, and searches skip them.", ), // Whether the daemon tells its host it is unneeded (system/quiet-watch.ts): off, the work signal carries no // `quietSince` and says `keepAwake`, so the machine's keeper never puts it to sleep (ic's `sandbox sleep`). Only a // sandbox on somebody's own computer sleeps that way; a hosted machine's idle stop is the plan's and ignores this. sleepWhenUnused: z .boolean() .default(true) .describe( "Let the computer this sandbox runs on put it to sleep after a stretch nobody needs it (no editor open, no agent working, no terminal typed in, no app of it open), which frees its memory. It starts again when somebody opens it, and a few minutes before anything it has scheduled comes due, so automations still run on time. Off keeps it running. Applies only to a sandbox on your own computer.", ), hashlineEdits: z .boolean() .default(false) .describe( "Have the agent edit files by line number rather than by quoting the text it wants replaced. Cheaper on large files, and less forgiving of a stale read.", ), // intentic: Claude Code's preset, read the same way, with what this harness does not want cut out (intentic-prompt.ts). // Default. // claude: Claude Code's preset, read live from the installed CLI, not a stored copy — tracks whatever prompt ships // with it. // custom: `systemPrompt` below, and nothing else. systemPromptMode: SystemPromptModeSchema.default("intentic").describe( "Which instructions the agent starts from: intentic's own, the ones the installed Claude Code carries, or your own. The first two both get this product's own guidance added on top; your own gets nothing added, which is the point of it.", ), // The delegation note still survives (it lives in the user-message preamble, not here). Cap is roomy but finite — // every turn pays for it. systemPrompt: z .string() .max(SYSTEM_PROMPT_MAX) .default("") .describe( "Your own instructions, used only when the mode above says custom. Then it is the whole of them: both built-in bases go, and so does everything this product would otherwise add, including the guidance the chat's own cards are driven by. That is the price of total control.", ), ...iqSearchSettings, ...claudeExperimentSettings, // Rooted at the run's own starting folder (a persona's, an isolated conversation's worktree), not the workspace // root — it maps the project containing that folder. workspaceMap: z .boolean() .default(false) .describe( "Open every conversation with a map of the project it starts in: what is in it, what each part is for, and where the agent is standing. Worked out fresh each time rather than written down anywhere, because a written layout is wrong within a fortnight. Off by default, since it spends tokens on the first message of every conversation.", ), // Judged on the opening turn's own directory listings, not averaged across the conversation, or a twelve-turn // conversation would divide the effect by twelve. From 2026-10-06 to 10-12 the held-out share got the full map // against a compact one; that changed nothing measurable, so the compact form went and the holdout opens without a // map again. workspaceMapHoldout: z .number() .min(0) .max(1) .default(0) .describe( "What share of conversations to open without the map, so the two can be compared. Whole conversations rather than individual turns, because the map is sent once and stays in the conversation's history afterwards.", ), // The one composed piece that is WRITTEN rather than derived: a monthly automation rewrites it off the session // corpus, so it carries what no scan of the tree can (which commands really work here, what the box can take, how // the owner asks for things) and the map keeps carrying what a scan can. fieldNotes: z .boolean() .default(false) .describe( "Open every turn with a brief on how work actually goes in this sandbox: the traps that cost past sessions calls, the commands that really verify, what the machine can take. Written once a month by an automation that reads back the sessions run here, rather than worked out per turn, because it is drawn from history rather than from the tree. Off by default, since it rides every turn of every conversation.", ), // Characters, not sections: the file's own ranking decides WHICH sections, this decides HOW MANY fit. Same unit as // the project map's ceiling so the two costs read on one scale. fieldNotesBudget: z .number() .int() .min(500) .max(20000) .default(4000) .describe( "How much of that brief to send. Its sections are ranked, most costly-to-not-know first, and they are taken whole in that order until this runs out — so raising it buys more of the tail, never a fuller version of the same thing.", ), fieldNotesHoldout: z .number() .min(0) .max(1) .default(0) .describe( "What share of conversations to run without the brief, so the two can be compared. Whole conversations rather than individual turns, because the brief sits in the prompt for the whole session and withholding it from one turn would not take it back.", ), outputCleaners: z .string() .default("") .describe("Which command outputs to trim before the agent reads them, cutting the noise a build tool prints without cutting what it said."), outputHoldout: z .number() .min(0) .max(1) .default(0) .describe("What share of commands to leave untrimmed, so the saving can be measured against a real comparison rather than estimated."), // The model lists jobs run on, edited once for every job put there (models/model-setups.ts). The three presets need // no entry: absent, each is worked out from what is connected, so a sandbox runs before anyone opens this page. modelSetups: z .array(ModelSetupSchema) .max(20) .default([]) .describe( "The model setups jobs run on: Low, Medium and High, which every sandbox has, and any you make. Each is an ordered list of models, tried in order so one spent account does not take its jobs down. A preset left out of this list, or listed without models, is worked out from the accounts you have connected. A setup runs only the jobs put on it (`roleSetups`).", ), // `partialRecord`, not `record`: an exhaustive one would force every settings file to list every role, and adding // one would invalidate them all. Absent IS "on no setup": nothing runs a job nobody placed. roleSetups: z .partialRecord(ModelRoleSchema, z.string().regex(MODEL_SETUP_ID)) .default({}) .describe( "Which setup each job runs on: commit messages, session titles, the safety judge, pipeline fixes and every other place this sandbox picks a model for you. Nothing is chosen for you: a job left out, or on a setup with no models, has none, so a one-shot job does not run and a whole session opens on whatever your own chat is set to.", ), // Folded into the router's prompt (agent/prompt/chat-router.ts), never into a turn's own. Capped small on // purpose: the reading runs under a 5s deadline, and this text is paid for on every chat that opens on Auto. autoModelGuidance: z .string() .max(AUTO_MODEL_GUIDANCE_MAX) .default("") .describe( "What you would tell somebody choosing the model for a new chat on your behalf: which model you want the cheap work on, which account to leave alone, when to reach for the strongest one. Read once per chat, alongside the models and allowances this sandbox can actually run, and it overrides the product's own advice where the two disagree. It cannot invent a model: the answer is still a choice from that list.", ), // Named by repo id ("root" or discoverRepos's dir); a commit spanning repos gets the trailer only where one was // asked for. changelogRepos: z .array(z.string()) .max(50) .default([]) .describe( "Which repositories keep a changelog, and so get a user-facing note written alongside each merge. A list rather than a switch, and empty by default, because the commit writer's standing rule is to copy the house style rather than impose one, and a repository that has never written such a note gives it nothing to copy.", ), agentRetentionDays: AgentRetentionDaysSchema, heldCheckoutDays: HeldCheckoutDaysSchema, // One answer per ending, never a set of switches over the same event: the chat's own question and these rows are // the same question at two scopes. Every ending defaults to `wait`, because a re-run spends the reader's allowance // on a turn they sent once. limitPolicy: LimitPolicySchema.default("wait").describe( "What happens to a turn a spent usage limit refused. `wait` holds it for a press. `resend` sends it again by itself once the allowance reopens, which needs a provider that publishes a reset (Grok and Cursor publish none). `move` also tries another connected account of the same provider that still has room, as soon as the refusal lands, and keeps the reset as its fallback. The sandbox-wide default; any one conversation can say otherwise.", ), // Off still records the failure, so the per-conversation offer arms normally; nothing is lost, just not automatic. outagePolicy: RetryPolicySchema.default("wait").describe( "What happens to a turn the model provider's own failure killed. `wait` holds it for a press. `retry` re-runs it on the shared per-provider breaker, backing off between attempts. The sandbox-wide default; any one conversation can say otherwise. Worth `retry` for a sandbox whose work mostly happens with nobody in the room.", ), // A vendor loop's hard cap on one turn; half an hour ended two Gemini children mid-work as failures (2026-10-17). turnCapMinutes: z.number().int().min(5).max(1440).default(30).describe( "The longest one turn may run on a loop the sandbox drives itself (OpenCode, an ACP agent, Pi, Muse) before it is ended, in minutes, however busy it keeps. A subagent's turn that reaches it is carried on in a fresh turn of the same session rather than ended as a failure. The Claude Code and Codex loops have no such cap.", ), // The posture that used to live only in a browser tab; moving it here is what lets it fire with nothing open. stopPolicy: RetryPolicySchema.default("wait").describe( "What happens to a turn that stopped short with nothing to repair — a hung runtime, a crashed harness. `wait` holds it for a press. `retry` re-runs the held turn on a short ladder, standing down after three tries that got nowhere rather than looping forever.", ), // The one ending that defaults to going by itself: nothing ran, so sending it once there is room spends nothing twice, // and it is what the person meant when they pressed Send. memoryPolicy: RoomPolicySchema.default("resend").describe( "What happens to a message the sandbox held because it was short of memory: nothing ran, so nothing was spent. `resend` sends it by itself once memory has stayed free for a while, one held conversation at a time. `wait` holds it until somebody presses Send anyway. The sandbox-wide default; any one conversation can say otherwise.", ), // How a held turn continues once its cache has gone cold (schemas/providers/handoff.ts). The card asks each time and // preselects this; a booked send (a move, a resend at the reset) uses it unless a person picked on the card. limitHandoff: LimitHandoffSchema.default("suggested").describe( "How a turn a spent usage limit held continues, on this account at the reset or on another: `carry` resumes the session as it is (the model keeps everything and re-reads all of it), `trim` resumes a copy with older tool output cleared (every message and tool call stays), `summary` opens a fresh session with a smaller model's summary and the last exchanges word for word. `suggested` picks by the conversation's size, using the two thresholds beside this. The chat's card preselects it and lets you pick another for that turn.", ), // Read by the `suggested` hand-off only. Kept under its old name: before 2026-10-10 it chose between carrying and a // short record hand-off for a move, and a saved value still means "carry under this". limitMoveCarryUnder: z .number() .int() .min(0) .default(150_000) .describe( "For the suggested hand-off: carry the session as it is while the conversation's context is under this many tokens; at or above it, trim it (or summarise it, past the next threshold). Zero never suggests carrying.", ), handoffSummaryOver: z .number() .int() .min(0) .default(700_000) .describe( "For the suggested hand-off: at or above this many tokens of context, suggest a summary rather than a trimmed session, where a model is set for hand-off summaries.", ), // Off by default: every refresh spends the account's own allowance on a conversation nobody is using yet. Before // 2026-09-26 these were four top-level settings (settings-history.ts folds them). keepWarm: KeepWarmSettingsSchema.prefault({}).describe( "Keeping an idle conversation's prompt cache warm, so coming back to it hours later costs a cache read instead of re-sending everything. Any one conversation can be kept warm or let cool by hand whatever `auto` says.", ), // Worth it since the container is recreated on every update or environment approval — otherwise approving a // Dockerfile change costs the run that asked for it. // What happens when main's CI fails (ci/main-fixer.ts): its first failed job starts one fix agent, which is sent // every later failure on main until main passes, within a few turns, and a failure on the CI fleet itself is re-run // once instead. Off, it is only reported. autoRepair: z .boolean() .default(true) .describe( "Whether main's CI failing is repaired without asking. The first job that fails on main starts one fix agent, without waiting for the rest of the run, and every later failure on main goes to that same agent until a run passes. It gets a few turns; when they are spent, or it finishes without changing anything (a failure that is not in the code), the failure waits for you. A failure on the CI fleet itself is re-run once instead. Off, all of it is only reported.", ), // What another writer pushes reaches the main tree within minutes instead of at the owner's next pull // (git/remote/follow-origin.ts), so an agent whose work clashes with it meets the clash at its own land. followOrigin: z .boolean() .default(true) .describe( "Whether each workspace repo keeps up with the remote branch it tracks. Every couple of minutes it is fetched, and what arrived is brought into the main tree: a fast-forward when you have no commits of your own, else a merge commit. Only while it is quiet (no turn working in the main tree, no merge or rebase of yours open), and never half-way: a conflict, or an uncommitted file in the way, leaves the repo exactly as it was until the next try. Nothing is ever pushed. Off, nothing is fetched.", ), // Where heavy work runs: a runner (the same image, on one of the owner's machines) instead of this sandbox. Keyed by // the heavy-command rule an agent's command matched (workload/heavy-commands.ts), each naming a runner id. A // runner that is offline, outdated or full hands the work back here, which the command's output says. Never pushed // to a runner itself (definition/definition.ts), which must not pass work on again. // `sessions`: what a new conversation is told when this machine is busy and a runner has room // (runners/fleet-advice.ts). Suggesting is the default: it costs one line above the composer, and moving a session // without being asked is the owner's choice to make. offload: z .object({ commands: z.record(z.string().min(1), z.string().min(1)).default({}), sessions: SessionsPlacementSchema.default("suggest"), }) .default({ commands: {}, sessions: "suggest" }) .describe( "Which work runs on a runner on one of your machines instead of this sandbox. `commands`: agents' heavy commands by the kind the heavy-command rules sort them into (tests, typechecks, verify…); the code travels as it stands, uncommitted work included, the output streams back, and any file the command changed comes back with it, while a machine that is offline, outdated or busy hands the work back to this sandbox, and the output says so. `sessions`: what happens to a new conversation while this machine is busy and one of your runners has room: `suggest` offers that machine above the composer, `auto` starts it there unless you keep it here, `off` says nothing.", ), // A person answering a need is exactly the moment they want the work to go on (docs/architecture/needs.md), unlike a // restart nobody watched, so this one is on to begin with. continueWhenNeedMet: z .boolean() .default(true) .describe( "Whether a conversation carries on by itself once something it asked a person for arrives: a connection made, a secret given, access allowed, a tool built into the image. Off leaves the answer on the conversation's card until someone sends a message.", ), autoResumeOnRestart: z .boolean() .default(false) .describe( "Whether a turn killed by the sandbox restarting is re-run once it comes back. A switch rather than one of the policies above, because a restart is the one ending with nobody watching it, so there is no in-chat question to answer. Off to begin with: it would spend your allowance on work you are not watching and edit files while you are still waiting for the sandbox to return. Either way the interruption is recorded rather than silently lost.", ), // Which repositories' own declarations (`/.intentic/checks.json`) the owner has switched on, each against the // fingerprint of what was declared when they did. A declaration that has since changed no longer matches its // fingerprint: the daemon finds the version that did in the repository's history, keeps running each check that is // still word for word one of its checks, and holds every new or rewritten one, which is what makes adoption a // decision about a command rather than a permanent permission on a folder, without one added check switching off // all the others. Keyed by repo id, so `/` in the key is ordinary. adoptedChecks: z .record(z.string(), z.string()) .default({}) .describe( "Which repositories may run the checks they declare for themselves, and exactly which version of those checks you agreed to. A repository's declaration does nothing until it appears here, the same rule git keeps for hooks, which are never cloned; and a check it adds or rewrites afterwards waits until you look at it again, while the ones you agreed to keep running.", ), // The owner's alone, since a rule can hold work. A command is refused here: it belongs to the repository it checks, // in that repository's `.intentic/checks.json`, never in settings. rules: z .array(RuleSchema) .max(50) .default([]) .refine((rules) => rules.every((rule) => rule.action.kind !== "command"), { message: "a command belongs in the repository's own .intentic/checks.json, not in settings", }) .describe( "Standing decisions about the sandbox's own work: land or hold finished work, save a version of what landed. Empty is the default and is exactly the behaviour of a fresh sandbox, because each of those defaults is what no rule matched means at its own moment. A command to run is a repository's own check, declared in its .intentic/checks.json.", ), automationFailureLimit: z .number() .min(0) .max(20) .default(0) .describe( "How many failures in a row before an automation switches itself off. Zero means never, which is the default, because the failure is not always the automation's fault and a job disabled at three in the morning is one nobody re-enables. Only real errors count: a guard deciding there was nothing to do, or the sandbox dying mid-run, say nothing about the automation.", ), // Speaking replies aloud (schemas/voice.ts): which voice speaks each language, and whether a hands-free turn is // answered out loud. Read by the daemon's voice engine on every utterance, so a change is heard on the next one. voice: VoiceSettingsSchema.prefault({}).describe( "How the agent's replies are spoken aloud: whether a hands-free conversation is answered out loud, which voice speaks each language, and a speech provider of your own if you connected one.", ), // What a data protection officer asks about (schemas/data-protection.ts). Restore points default to thirty days; // the other policies preserve the earlier behaviour until the owner changes them or applies the public-office preset. retention: RetentionSettingsSchema.prefault({}).describe( "How long this sandbox keeps each kind of copy it makes of what people and agents wrote: transcripts, restore points, the privacy shield's memory, converted documents and raw command output. Restore points default to 30 days; the other copies default to forever.", ), audit: AuditSettingsSchema.prefault({}).describe( "The audit trail: who changed a policy, a member, a gate or a setting, and optionally who read what. How long it is kept, and where a copy is sent.", ), // Read by the editor, not the daemon: the product analytics run in the browser, so the switch is honoured there for // as long as this sandbox is the one open. productAnalytics: z .boolean() .default(true) .describe( "Let Intentic's product analytics (usage events and session recordings in the editor, for people who agreed to them) run while this sandbox is open. Off sends nothing about any visit to this sandbox, whatever each person agreed to.", ), aiNotice: AiNoticeSchema.prefault({}).describe( "A notice members confirm once, telling them they are working with an AI and what to check before relying on it.", ), // Defaults all-allow, so a fresh sandbox behaves as it did before this floor existed. admission: AdmissionPolicySchema.prefault({}).describe( "Whether work started from outside may run, per kind of trigger: let it, hold it for approval, or refuse it. Composes with each automation's own setting, and the stricter of the two wins, so holding every visitor's message needs no edit to each automation.", ), // Keyed by `.` ("discord.message.send"), or `.*` as a wildcard — exact key wins; // unconfigured is allowed. "hold" can't park a live call: it refuses and points to the approvals queue. The // child-agent surface (`agents.spawn`/`agents.spawn.`) reads the same book, plus its own taint floor for // spawns from tainted turns. actionRules: z .record(z.string(), AdmissionRuleSchema) .default({}) .describe("What an agent may do out in the world, per kind of action: go ahead, ask first, or never."), // Command policy itself lives at .intentic/config/safety.md (edited by the Safety page), not here; this only says // whether the judge runs at all. Which model judges is the `safety-judge` job's setup (`roleSetups`), not a field here. commandJudge: CommandJudgeModeSchema.default("on").describe( "Whether a model reads your safety policy before a flagged command runs. Off judges nothing and asks about nothing; Watch judges everything and records it without ever interrupting you, which is how you find out what your policy actually does before you let it stop anything; On lets the verdict decide. Wiping a disk or deleting under /history asks at every setting — that rule is typed rather than judged, and cannot be turned off.", ), // Only whether a person is asked: where the install lands (the conversation's own copy, or the daemon's install lane // for a main-tree turn) is the sandbox's business, so no setting names it. projectInstalls: ProjectInstallModeSchema.default("automatic").describe( "What happens when an agent installs a project's packages itself (pnpm add, npm install, uv sync). Automatic lets it run and keep working; Ask first stops for your answer in the chat, once or for the whole conversation; Never refuses, and a dependency the agent added to a manifest is installed when you land its work. A conversation in its own worktree installs into its own copy, and the land review lists every dependency its work adds.", ), // Three separate ceilings since they stop different things: width (parallel fan-out), lifetime (per conversation), // depth (how far a child may delegate) — raising width alone just hits the lifetime cap sooner. Defaults mirror the // CLI's own unset behavior; hitting one stops the agent rather than retrying. subagentsAtOnce: z.number().min(1).max(200).default(20).describe("How many subagents may work at the same time."), subagentsPerTurn: z.number().min(1).max(2000).default(200).describe("How many a single turn may start in total."), // Depth 1 means an agent may delegate but its children may not; unlike the other two, a runaway here is // multiplicative, not merely wide. subagentDepth: z .number() .min(1) .max(10) .default(3) .describe("How many levels deep the delegation may go, since a subagent can start subagents of its own."), // How far off a reset a spawned child's spent turn is booked to re-run by itself (classify-failure.ts); one with fallbacks // keeps 90 minutes. 2026-10-18: was a fixed 90 minutes, which ended every child behind a five-hour window. childRerunHorizonHours: z.number().int().min(0).max(24).default(6).describe( "How long a subagent refused for a spent allowance waits for the provider's reset before it goes again by itself. Past it, or at 0, it stops and its parent hears where else the work can go. One spawned with fallbacks waits at most 90 minutes and then moves to them.", ), subagentBudget: z.boolean().default(true).describe("Start a subagent only where an account's allowance can carry it through its windows, on the account left with the most room after it. The rest wait as pending and start by themselves as running ones end or a window reopens. Off, every subagent starts at once, wherever the most room reads now."), subagentReservePercent: z.number().int().min(0).max(50).default(10).describe("How much of each allowance window, in points of 100, subagents leave for your own conversations: a fan-out is planned against what is left past it."), // Width again, for a team-chat automation rather than a turn: each thread a message lands in is a conversation of // its own, and threads of one automation are worked on side by side up to this many (automations/scheduler.ts in // the daemon); one more waits, told it is queued, until a slot frees. Read per fire, so a change holds from the next // thread on. automationThreadsAtOnce: z .number() .min(1) .max(20) .default(3) .describe( "How many threads of one team-chat automation may be worked on at the same time. Each is its own agent turn, so this is how many agents one busy channel can start at once.", ), }); export type SandboxSettings = z.infer; // What a save takes: the settings as read, less what is only read. `turn.ending` holds only the checks a repository // declares, which the daemon adds to a turn's rules and never saves, and the `verify-ui-edits` built-in is retired; a // file written before still parses (and its conversion drops such rules), but no save may stand a new one. export const SandboxSettingsWriteSchema = SandboxSettingsSchema.refine((settings) => !settings.rules.some(isRetiredMomentRule), { message: "turn.ending runs only the checks a repository declares in its own .intentic/checks.json, so a rule cannot stand there", path: ["rules"], }); // A browser's answer to who the screens are written for, for the person signed in to it. `offer` is the one that must // not overwrite: a browser handing over the answer it kept on its own, taken only while the sandbox keeps none for them. export const AudienceAnswerSchema = z.object({ audience: AudienceSchema.describe("developer for git's own words, maker for plain ones."), offer: z .boolean() .optional() .describe("Take it only while this person has no answer kept here yet. Absent or false replaces whatever is kept."), }); export type AudienceAnswer = z.infer; // What the sandbox keeps for this person after the answer, and whether this call set it: false is an offer it declined. export const AudienceStateSchema = z.object({ audience: AudienceSchema.describe("The words this sandbox's editor now uses."), adopted: z.boolean().describe("Whether this call set it. False means an offer met an answer already kept, which stands."), }); export type AudienceState = z.infer; // What a person has said about the editor's getting-started checklist on this sandbox, kept per member like the // audience so it reads the same on every device. Only what the sandbox's own data cannot answer: whether a step is DONE // is read from that data each time (a workspace with files, an agent started, a model connected, work landed), so this // holds nothing but the reader's own choices. Step names are the editor's, opaque here. export const GettingStartedSchema = z.object({ hidden: z.boolean().optional().describe("Whether this person put the checklist away. Absent until they have."), skipped: z .array(z.string().min(1).max(40)) .max(20) .optional() .describe("The checklist steps this person passed over, by the editor's own step names."), }); export type GettingStarted = z.infer; // A browser telling the sandbox which clock IT is on. An offer, not an instruction: see `adoptTimezone`. export const TimezoneOfferSchema = z.object({ timezone: ZoneSchema.describe("The zone the offering machine is in, as an IANA name like Europe/Warsaw."), }); export type TimezoneOffer = z.infer; // What the sandbox's clock is after the offer, and whether the offer is what set it. `adopted: false` with a zone // back means somebody had already chosen, which is the normal answer for every browser after the first. export const TimezoneStateSchema = z.object({ timezone: z.string().describe("The zone this sandbox's schedules are now read in. Empty only if none could be resolved."), adopted: z.boolean().describe("Whether this call is what set it. False means it was already answered and the stored zone stands."), }); export type TimezoneState = z.infer; // Read live from the installed CLI (preset-prompt.ts), not a stored transcription. `version` is the CLI build it came // from, so a fork from an older build reads as a snapshot. export const BuiltinPromptTextSchema = z.object({ text: z.string(), version: z.string() }); export type BuiltinPromptText = z.infer; // What each token-reduction mechanism saved, and the comparisons that measure the turn-level ones, in a file of their own. export * from "./settings-savings.js"; // ---- what a REPOSITORY declares for itself, at `/.intentic/checks.json` ---- // Here rather than in a file of its own because the line between these shapes and the settings above is authority, not // subject matter: a repository may declare WHAT to run, because the command belongs beside the scripts it names and // travels with the checkout; only the sandbox owner decides whether it runs at all (`adoptedChecks` above) and what // happens when it fails, and that stays in settings.json. Both halves of that one decision read together here, and the // routes that carry them are the settings routes. // Where a repository declares them, under the same `.intentic/` folder a directory already uses for its own UI. One // spelling for the daemon that reads it, the screen that names it and the demo that mimics it. export const REPO_CHECKS_FILE = `${STATE_DIR}/checks.json`; // Named for the occasion as a repository would say it: `edit` is `file.edited` and `turn` is `turn.ending` // (rules/repo-checks.ts maps them). `edit` runs on one file (`{file}`) and its output rides back in the edit's own // response, so a command declared there is paid per edit and has to take the file. `turn` runs once when an isolated // turn is about to stop, on the whole tree, and what it prints when it fails is said back to the model once, which may // fix it or say why not. `land` is retired: nothing runs after work lands any more (CI checks what the owner pushes), and // a declaration naming it still reads but runs nothing. No check refuses a land, a commit or a push. export const RepoCheckMomentSchema = z.enum(["edit", "turn", "land"]); export type RepoCheckMoment = z.infer; export const RepoCheckSchema = z.object({ when: RepoCheckMomentSchema.describe( "When to run it: `edit` on each file as it is written (`{file}` is its path); `turn` once when an isolated turn is about to stop, for at most three minutes, with what it prints on failure said back to the model once. Neither holds or refuses anything. `land` is retired and runs nothing, since CI checks what is pushed.", ), run: z.string().min(1).max(500).describe("The command, run in this repository's own directory, so it reads as it would in a terminal there."), label: z.string().min(1).max(80).optional().describe("What to call it on screen. Absent names it after the command."), // Same ceiling as a rule's own command; past it the process group is killed and the run is a failure. timeoutMs: z.number().min(60_000).max(3_600_000).optional().describe("How long it may take before it is killed and counted as failed."), // Repo-relative; the daemon prefixes the repo id before matching, since a rule's globs are workspace-relative. paths: z .array(z.string().min(1)) .max(20) .optional() .describe("Only run it when the change touches these paths, written relative to this repository. Absent runs it on every change here."), }); export type RepoCheck = z.infer; // The file itself. One key, so a second concern can be added later without breaking a file anyone has written. export const RepoChecksFileSchema = z.object({ checks: z .array(RepoCheckSchema) .max(10) .default([]), }); export type RepoChecksFile = z.infer; // One repository, as a screen reads it: what it declares, and where that stands with the owner. export const RepoChecksSummarySchema = z.object({ repo: z.string().describe('Which repository, by its workspace id ("root" is the workspace itself).'), path: z.string().describe("Where the declaration lives, relative to the workspace, whether or not the file exists yet."), checks: z.array(RepoCheckSchema).describe("What it declares, in the order the file lists them."), fired: z .array(z.number().nullable()) .describe( "When each declared check last reported something, in the file's order, as epoch milliseconds; null for one that never has, or for a retired one, which runs nothing.", ), adopted: z .boolean() .describe("Whether these are running. False means declared and inert: nothing a repository writes runs until the owner switches it on."), changed: z .boolean() .describe( "Whether the declaration gained or rewrote a check since it was adopted. What changed waits until the owner looks again; the checks the owner already agreed to, word for word, keep running (`held` says which is which). True only for a repository that was adopted before.", ), held: z .array(z.boolean()) .optional() .describe( "For a changed declaration, which of its checks wait for the owner, in the file's order: true for one that is new or rewritten since they agreed, false for one still running as agreed, or retired. Absent when nothing changed, and from a sandbox that holds a changed declaration whole.", ), error: z.string().optional().describe("Why the file could not be read, when it exists but does not parse. The checks list is empty in that case."), }); export type RepoChecksSummary = z.infer; export const RepoChecksListSchema = z.object({ repos: z.array(RepoChecksSummarySchema).describe("Every repository that declares checks, in id order."), }); export type RepoChecksList = z.infer; export const RepoChecksAdoptSchema = z.object({ repo: z.string().min(1).describe("Which repository's declaration to switch."), on: z .boolean() .describe("On adopts what it declares as it stands now; off stops running it. Adopting again is how a changed declaration is accepted."), }); export type RepoChecksAdopt = z.infer;