@intentic/sandbox-contract 1.359.2 → 1.361.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/dist/contracts/agent.contract.d.ts +9 -0
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/agents.contract.d.ts +21 -0
  4. package/dist/contracts/agents.contract.d.ts.map +1 -1
  5. package/dist/contracts/automations.contract.d.ts +2 -0
  6. package/dist/contracts/automations.contract.d.ts.map +1 -1
  7. package/dist/contracts/needs.contract.d.ts +7 -0
  8. package/dist/contracts/needs.contract.d.ts.map +1 -1
  9. package/dist/contracts/runner.contract.d.ts +3 -0
  10. package/dist/contracts/runner.contract.d.ts.map +1 -1
  11. package/dist/contracts/sessions.contract.d.ts +1 -0
  12. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  13. package/dist/contracts/system.contract.d.ts +33 -30
  14. package/dist/contracts/system.contract.d.ts.map +1 -1
  15. package/dist/events/agent-events.d.ts +4 -0
  16. package/dist/events/agent-events.d.ts.map +1 -1
  17. package/dist/events/agent-transcript.d.ts +1 -0
  18. package/dist/events/agent-transcript.d.ts.map +1 -1
  19. package/dist/events/land-conflict.d.ts +10 -0
  20. package/dist/events/land-conflict.d.ts.map +1 -1
  21. package/dist/events/land-conflict.js +47 -0
  22. package/dist/events/land-conflict.js.map +1 -1
  23. package/dist/events/system-events.d.ts +2 -0
  24. package/dist/events/system-events.d.ts.map +1 -1
  25. package/dist/events/transcript.d.ts +5 -0
  26. package/dist/events/transcript.d.ts.map +1 -1
  27. package/dist/index.d.ts +43 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/models/agent-catalog.d.ts.map +1 -1
  30. package/dist/models/agent-catalog.js +1 -0
  31. package/dist/models/agent-catalog.js.map +1 -1
  32. package/dist/models/agent-lanes.d.ts +8 -3
  33. package/dist/models/agent-lanes.d.ts.map +1 -1
  34. package/dist/models/agent-lanes.js +16 -3
  35. package/dist/models/agent-lanes.js.map +1 -1
  36. package/dist/models/agent-runtimes.d.ts +7 -1
  37. package/dist/models/agent-runtimes.d.ts.map +1 -1
  38. package/dist/models/agent-runtimes.js +17 -8
  39. package/dist/models/agent-runtimes.js.map +1 -1
  40. package/dist/models/agent-stack.d.ts +12 -0
  41. package/dist/models/agent-stack.d.ts.map +1 -0
  42. package/dist/models/agent-stack.js +19 -0
  43. package/dist/models/agent-stack.js.map +1 -0
  44. package/dist/models/module-of.d.ts +3 -0
  45. package/dist/models/module-of.d.ts.map +1 -0
  46. package/dist/models/module-of.js +13 -0
  47. package/dist/models/module-of.js.map +1 -0
  48. package/dist/models/provider-specs.d.ts +2 -0
  49. package/dist/models/provider-specs.d.ts.map +1 -1
  50. package/dist/models/provider-specs.js +2 -0
  51. package/dist/models/provider-specs.js.map +1 -1
  52. package/dist/policy/persona-capability.d.ts.map +1 -1
  53. package/dist/policy/persona-capability.js +2 -1
  54. package/dist/policy/persona-capability.js.map +1 -1
  55. package/dist/schemas/agents-list.d.ts +2 -0
  56. package/dist/schemas/agents-list.d.ts.map +1 -1
  57. package/dist/schemas/agents.d.ts +3 -0
  58. package/dist/schemas/agents.d.ts.map +1 -1
  59. package/dist/schemas/agents.js +5 -1
  60. package/dist/schemas/agents.js.map +1 -1
  61. package/dist/schemas/automation-events.d.ts +40 -0
  62. package/dist/schemas/automation-events.d.ts.map +1 -0
  63. package/dist/schemas/automation-events.js +20 -0
  64. package/dist/schemas/automation-events.js.map +1 -0
  65. package/dist/schemas/automations.d.ts +4 -38
  66. package/dist/schemas/automations.d.ts.map +1 -1
  67. package/dist/schemas/automations.js +8 -18
  68. package/dist/schemas/automations.js.map +1 -1
  69. package/dist/schemas/device-commands.d.ts.map +1 -1
  70. package/dist/schemas/device-commands.js.map +1 -1
  71. package/dist/schemas/hosts.d.ts.map +1 -1
  72. package/dist/schemas/hosts.js.map +1 -1
  73. package/dist/schemas/needs.d.ts +8 -0
  74. package/dist/schemas/needs.d.ts.map +1 -1
  75. package/dist/schemas/organisation.d.ts.map +1 -1
  76. package/dist/schemas/organisation.js.map +1 -1
  77. package/dist/schemas/personas.d.ts +13 -0
  78. package/dist/schemas/personas.d.ts.map +1 -1
  79. package/dist/schemas/personas.js +9 -0
  80. package/dist/schemas/personas.js.map +1 -1
  81. package/dist/schemas/providers/plan-limits.js +2 -2
  82. package/dist/schemas/providers/plan-limits.js.map +1 -1
  83. package/dist/schemas/repo-checks.d.ts +84 -0
  84. package/dist/schemas/repo-checks.d.ts.map +1 -0
  85. package/dist/schemas/repo-checks.js +50 -0
  86. package/dist/schemas/repo-checks.js.map +1 -0
  87. package/dist/schemas/run-targets.d.ts.map +1 -1
  88. package/dist/schemas/run-targets.js.map +1 -1
  89. package/dist/schemas/settings.d.ts +2 -82
  90. package/dist/schemas/settings.d.ts.map +1 -1
  91. package/dist/schemas/settings.js +7 -49
  92. package/dist/schemas/settings.js.map +1 -1
  93. package/dist/state/daemon-floor.d.ts.map +1 -1
  94. package/dist/state/daemon-floor.js.map +1 -1
  95. package/dist/text/preview-guide.d.ts.map +1 -1
  96. package/dist/text/preview-guide.js.map +1 -1
  97. package/package.json +13 -13
  98. package/src/events/land-conflict.ts +78 -3
  99. package/src/models/agent-catalog.test.ts +10 -7
  100. package/src/models/agent-catalog.ts +1 -0
  101. package/src/models/agent-lanes.ts +37 -12
  102. package/src/models/agent-runtimes.ts +50 -14
  103. package/src/models/agent-stack.test.ts +86 -0
  104. package/src/models/agent-stack.ts +48 -0
  105. package/src/models/capability-ledger.test.ts +4 -1
  106. package/src/models/module-of.ts +20 -0
  107. package/src/models/provider-specs.test.ts +6 -2
  108. package/src/models/provider-specs.ts +7 -0
  109. package/src/policy/persona-capability.ts +10 -3
  110. package/src/schemas/agents.ts +8 -1
  111. package/src/schemas/automation-events.ts +35 -0
  112. package/src/schemas/automations.ts +12 -33
  113. package/src/schemas/device-commands.ts +5 -17
  114. package/src/schemas/hosts.ts +4 -27
  115. package/src/schemas/organisation.ts +4 -16
  116. package/src/schemas/personas.ts +27 -0
  117. package/src/schemas/providers/plan-limits.ts +3 -3
  118. package/src/schemas/repo-checks.ts +81 -0
  119. package/src/schemas/run-targets.ts +3 -19
  120. package/src/schemas/settings.ts +9 -88
  121. package/src/state/daemon-floor.ts +4 -15
  122. package/src/text/preview-guide.ts +4 -16
@@ -6,6 +6,16 @@
6
6
  // that grants it.
7
7
  export type ExecutionBackend = "shell" | "js";
8
8
 
9
+ // Which origins of skills a runtime's loader reads by itself (AgentCapabilities.skillDiscovery).
10
+ export interface SkillDiscovery {
11
+ readonly workspace: boolean;
12
+ readonly builtIn: boolean;
13
+ readonly kit: boolean;
14
+ }
15
+
16
+ const EVERY_SKILL: SkillDiscovery = { workspace: true, builtIn: true, kit: true };
17
+ const NO_SKILL_LOADER: SkillDiscovery = { workspace: false, builtIn: false, kit: false };
18
+
9
19
  export interface AgentCapabilities {
10
20
  // Which loop serves the turn; `opencode-gemini` stays separate since adapter health keys off this field.
11
21
  readonly runtime: "claude-code" | "codex" | "opencode" | "opencode-gemini" | "acp" | "devin" | "kimi-cli" | "grok-build" | "pi" | "cursor" | "muse";
@@ -41,8 +51,12 @@ export interface AgentCapabilities {
41
51
  readonly warm: boolean;
42
52
  // How much of the owner's instructions this runtime takes: replace the base, append to it, or no seam at all.
43
53
  readonly instructions: "replace" | "append" | "none";
44
- // How the runtime discovers loaded skills: scans its own filesystem, or the daemon names them in the opener.
45
- readonly skillDiscovery: "native" | "prompt";
54
+ // Which of a turn's skills the runtime's own loader finds; the rest are named, with their paths, in the note a fresh
55
+ // session opens with (the sandbox's agent/run/decide/turn-skills.ts), less the ones the turn may not use:
56
+ // workspace - the workspace's loaded skills (.agents/skills, or the .claude/skills projection of them);
57
+ // builtIn - the product's own skills the image bakes (/root/.claude/skills);
58
+ // kit - the worn persona's own skills, which the daemon hands the loader per turn.
59
+ readonly skillDiscovery: SkillDiscovery;
46
60
  // Whether the owner's safety policy reaches this runtime, and how:
47
61
  // hooks - its own pre-execution hook can park a call and wait for a card (Claude Code only).
48
62
  // approval - a vendor approval channel the daemon answers from policy; the vendor picks which calls to raise.
@@ -69,6 +83,12 @@ export interface AgentCapabilities {
69
83
  // every tool call the loop makes passes a check the daemon answers, or the loop is built with only the listed tools.
70
84
  // A runtime that runs some tool without asking anyone cannot keep it, so a turn carrying a list never starts on one.
71
85
  readonly toolAllowlist: boolean;
86
+ // When the owner's per-edit checks (the `file.edited` rules, the byte scan) reach the model: `after-edit` where the
87
+ // loop's own hook answers each edit or write as it lands (Claude Code's PostToolUse, Codex's and Pi's gate), so the
88
+ // model reads them beside the call; `turn-end` where nothing stands after a call, so the daemon reads the files the
89
+ // turn's edit calls touched as it ends and says the findings back as one more prompt, beside the repositories' turn
90
+ // checks (runtimes/decorators/ending-checks.ts).
91
+ readonly editChecks: "after-edit" | "turn-end";
72
92
  }
73
93
 
74
94
  // The Claude Code Agent SDK loop, the ceiling every other runtime is measured against: the only one that owns the whole
@@ -89,7 +109,7 @@ export const CLAUDE_CODE: AgentCapabilities = {
89
109
  recovery: true,
90
110
  warm: true,
91
111
  instructions: "replace",
92
- skillDiscovery: "native",
112
+ skillDiscovery: EVERY_SKILL,
93
113
  // The only runtime with its own pre-execution hook, so the only one where a hold can park instead of refusing.
94
114
  rulebook: "hooks",
95
115
  secrets: "masked",
@@ -98,6 +118,7 @@ export const CLAUDE_CODE: AgentCapabilities = {
98
118
  // A PreToolUse hook refuses every call off the list, under bypassPermissions and in subagents too
99
119
  // (agent/tools/tool-allowlist.ts).
100
120
  toolAllowlist: true,
121
+ editChecks: "after-edit",
101
122
  };
102
123
 
103
124
  // Codex app-server: item-level events, the turn's remote MCP servers and the daemon's own over http, per-thread hooks
@@ -131,7 +152,8 @@ export const CODEX: AgentCapabilities = {
131
152
  // thread/start's own fields: baseInstructions replaces Codex's base prompt, developerInstructions adds a developer
132
153
  // message.
133
154
  instructions: "replace",
134
- skillDiscovery: "native",
155
+ // Codex reads the workspace's `.agents/skills` itself; the built-ins and a persona's kit are named in the note.
156
+ skillDiscovery: { workspace: true, builtIn: false, kit: false },
135
157
  // A per-thread PreToolUse hook puts every function tool (the shell, apply_patch, every MCP tool, spawn_agent) to the
136
158
  // door's gate before it runs, which may park it on a card (codex-hooks.ts). Codex fails a hook open, so the gate is
137
159
  // confirmed connected before the turn starts, a command or patch Codex asks approval for runs only if the gate
@@ -139,7 +161,7 @@ export const CODEX: AgentCapabilities = {
139
161
  // connect the gate, so there the rules reach only the commands and patches Codex asks about.
140
162
  rulebook: "hooks",
141
163
  // Shell commands resolve `{{secret:…}}` in the gate's rewrite and run under a wrapper that masks stored values in
142
- // their output (codex-mask-script.ts); apply_patch, view_image and hosted tools pass no wrapper.
164
+ // their output (mask-script.ts); apply_patch, view_image and hosted tools pass no wrapper.
143
165
  secrets: "shell",
144
166
  // Its model provider's base_url is the daemon's, written into the per-thread config.
145
167
  privacy: "gateway",
@@ -148,6 +170,7 @@ export const CODEX: AgentCapabilities = {
148
170
  // too unless listed, and each MCP server is cut to its listed tools (`enabled_tools`). The residual: a known-safe
149
171
  // read-only command off the list runs if the in-process gate fails after it connected, and the turn is then stopped.
150
172
  toolAllowlist: true,
173
+ editChecks: "after-edit",
151
174
  };
152
175
 
153
176
  // OpenCode (the Grok runtime): its own agentic loop and tools, with the turn's remote MCP servers mounted on the shared
@@ -196,7 +219,9 @@ export const OPENCODE: AgentCapabilities = {
196
219
  // A prompt that replaces the base (the owner's own under `custom`, a small window's paragraph) runs on the daemon's
197
220
  // twin agents, whose base is blank, so it is the whole system text; anything else is added to OpenCode's own base.
198
221
  instructions: "replace",
199
- skillDiscovery: "prompt",
222
+ // The server's skill roots are the workspace's loaded skills and the built-ins (opencode.ts serverConfig); a persona's
223
+ // kit, which changes per turn on a server shared by every turn, is named in the note.
224
+ skillDiscovery: { workspace: true, builtIn: true, kit: false },
200
225
  // OpenCode raises its permission asks for every shell command, a subagent's included (the server's own rules ask about
201
226
  // them all, and a child session inherits them); the daemon answers from the rulebook, a hold parked on a card while
202
227
  // the turn's inactivity watchdog is held. The JS backend consults the same gate. Not `hooks`: which calls ask is still
@@ -213,6 +238,7 @@ export const OPENCODE: AgentCapabilities = {
213
238
  // session inherits them. Every built-in tool has its own permission action (checked against 2.0.26), and a shell entry
214
239
  // narrowed to a command is checked against each command's ask (runtimes/opencode/allowlist/opencode-allowlist.ts).
215
240
  toolAllowlist: true,
241
+ editChecks: "turn-end",
216
242
  };
217
243
 
218
244
  // The same OpenCode loop, serving Gemini instead of xAI, identical abilities. The Claude Code loop announces itself in
@@ -252,13 +278,14 @@ export const ACP: AgentCapabilities = {
252
278
  isolation: "cwd",
253
279
  commands: true,
254
280
  terminals: true,
255
- // A failure the agent words as a spent allowance or an outgrown window is coded (error-frames.ts codedFailure), but the
256
- // words are each agent's own and the protocol marks no outage, so nothing promises the codes auto-resume keys off.
257
- recovery: false,
281
+ // A failure the agent words as a spent allowance or an outgrown window is coded at the turn's one exit
282
+ // (error-frames.ts codedFailure), and an agent gone silent mid-turn is coded an outage by the shared watchdog
283
+ // (turn-watchdog.ts expiredFrame), so a turn the provider dropped is re-run once it is back, as every loop's is.
284
+ recovery: true,
258
285
  warm: false,
259
286
  // ACP's calls carry no system field: the agent owns its instructions, the persona note rides the message.
260
287
  instructions: "none",
261
- skillDiscovery: "prompt",
288
+ skillDiscovery: NO_SKILL_LOADER,
262
289
  // In the protocol floor, so every agent has it; which calls it asks about is entirely its own choice. A terminal the
263
290
  // daemon runs for it (terminal/create) is asked about here too, unless the permission for it already was.
264
291
  rulebook: "approval",
@@ -267,6 +294,7 @@ export const ACP: AgentCapabilities = {
267
294
  privacy: "none",
268
295
  // Which calls the agent asks permission for is its own choice, and most run reads without asking.
269
296
  toolAllowlist: false,
297
+ editChecks: "turn-end",
270
298
  };
271
299
 
272
300
  // Devin's own CLI (`devin acp`), the ACP floor with one seam an installed agent lacks: the daemon picks the model, from
@@ -339,7 +367,8 @@ export const PI: AgentCapabilities = {
339
367
  // keeps Pi from loading AGENTS.md a second time beside the daemon's own note.
340
368
  instructions: "replace",
341
369
  // `--skill` per loaded skill, the persona's filter applied, with Pi's own discovery off (`--no-skills`).
342
- skillDiscovery: "native",
370
+ // Pi's own discovery is off, and every skill the turn may use is handed to it by path (`--skill`, pi-adapter.ts).
371
+ skillDiscovery: EVERY_SKILL,
343
372
  // The daemon's extension, loaded with `-e` ahead of any other and whatever the card's command says of discovery, puts
344
373
  // every tool call (MCP and codemode calls included) to the turn's own hook socket before it runs, where a held one
345
374
  // parks on a card while Pi waits (pi-hooks.ts). A call it cannot ask about is refused, as a handler that throws is,
@@ -356,6 +385,7 @@ export const PI: AgentCapabilities = {
356
385
  // The same socket refuses every call off the turn's list, Pi's tools read under the Claude loop's names (bash as
357
386
  // Bash, find as Glob) and MCP tools under the same `mcp__<server>__<tool>`; fail closed as above.
358
387
  toolAllowlist: true,
388
+ editChecks: "after-edit",
359
389
  };
360
390
 
361
391
  // Cursor's own runtime, driven through `@cursor/sdk` from this daemon's process; an embedding surface, so most seams
@@ -372,7 +402,9 @@ export const CURSOR: AgentCapabilities = {
372
402
  permissions: "modes",
373
403
  // True since the daemon supplies its own ask tool; Cursor's own can fabricate an answer, so it's disallowed.
374
404
  questions: true,
375
- // The turn's remote MCP servers plus host callbacks; everything but a Claude Code plugin checkout.
405
+ // The turn's remote MCP servers, the daemon's own door servers among them (accounts, secrets, watch, deps,
406
+ // diagnostics), plus host callbacks for the three it runs in the daemon's process instead (ask, code, the subagent
407
+ // tools: cursor-tools.ts); everything but a Claude Code plugin checkout.
376
408
  mcp: "tools",
377
409
  // The JS backend rides Cursor's custom-tool seam rather than an MCP server, and consults the rulebook in its
378
410
  // handler, since the hook file only covers the shell.
@@ -401,7 +433,9 @@ export const CURSOR: AgentCapabilities = {
401
433
  // Cursor's own loader reads every SKILL.md under `.agents/skills/` of each workspace root it is given: the cwd holds
402
434
  // the loaded skills, and the worn persona's kit is laid out as a second root (`local.dirs`); a skill the persona
403
435
  // withholds is refused at the read tool, the seam the Claude loop's `Skill(<name>)` denial is (cursor-skills.ts).
404
- skillDiscovery: "native",
436
+ // The workspace's skills from the turn's own tree and the persona's kit as a projected root (cursor-skills.ts); the
437
+ // built-ins, outside every root it is given, are named in the note.
438
+ skillDiscovery: { workspace: true, builtIn: false, kit: true },
405
439
  // The full hook tier, the only foreign runtime to reach it: a hold parks since the vendor waits on the hook.
406
440
  rulebook: "hooks",
407
441
  // Every shell command runs through the hook script: a `{{secret:name}}` reference resolves into what the script runs once
@@ -421,6 +455,7 @@ export const CURSOR: AgentCapabilities = {
421
455
  // cannot place in a turn. A turn carrying a list does not start unless those hooks are installed, and a hook the
422
456
  // daemon cannot answer refuses a live turn's call.
423
457
  toolAllowlist: true,
458
+ editChecks: "turn-end",
424
459
  };
425
460
 
426
461
  // Muse Code, Meta's coding agent, driven over the Muse Session Protocol: one `muse serve` host per turn, born in the turn's
@@ -460,7 +495,7 @@ export const MUSE: AgentCapabilities = {
460
495
  instructions: "none",
461
496
  // Muse loads a trusted workspace's own skills from its own directories, which are not the daemon's; the daemon names
462
497
  // the loaded ones in the opener.
463
- skillDiscovery: "prompt",
498
+ skillDiscovery: NO_SKILL_LOADER,
464
499
  // Muse's approval channel, answered from the rulebook; which commands Muse runs without asking (its own allow rules)
465
500
  // is its choice.
466
501
  rulebook: "approval",
@@ -469,4 +504,5 @@ export const MUSE: AgentCapabilities = {
469
504
  privacy: "none",
470
505
  // Edits and reads run without asking anyone, so no answer the daemon gives can keep a call off a list.
471
506
  toolAllowlist: false,
507
+ editChecks: "turn-end",
472
508
  };
@@ -0,0 +1,86 @@
1
+ import type { AgentStatus } from "../schemas/agents.js";
2
+ import { attentionCalls, attentionCards } from "./agent-lanes.js";
3
+ import { automationOf, stackHeads } from "./agent-stack.js";
4
+
5
+ const none = { plan: false, question: false, permission: false, capability: false, credential: false, conflict: false };
6
+ const LANDED: AgentStatus = `landed`;
7
+ const ERROR: AgentStatus = `error`;
8
+
9
+ type Over = { status?: AgentStatus; updatedAt?: number; startedAt?: number; startedBy?: string; failureCode?: string; attention?: typeof none };
10
+
11
+ // One run of the automation `x-follow`, as the daemon now records it, settled at `updatedAt`.
12
+ const run = (id: string, over: Over = {}) => ({
13
+ id,
14
+ status: LANDED,
15
+ attention: none,
16
+ updatedAt: 0,
17
+ origin: { automationId: `x-follow`, provider: `schedule` },
18
+ ...over,
19
+ });
20
+ const person = (id: string, over: Over = {}) => ({ id, status: LANDED, attention: none, updatedAt: 0, ...over });
21
+
22
+ describe("automationOf", () => {
23
+ test("names the automation from the origin, else from the id the daemon minted the run under", () => {
24
+ expect(automationOf({ id: `anything`, origin: { automationId: `x-follow` } })).toBe(`x-follow`);
25
+ // Runs a schedule fired before origins were recorded: `a-<automation>-<base36 stamp>`.
26
+ expect(automationOf({ id: `a-x-follow-mvn5x1jtu` })).toBe(`x-follow`);
27
+ expect(automationOf({ id: `a-nightly-mvn5x1j16` })).toBe(`nightly`);
28
+ });
29
+
30
+ test("names none for a conversation a person started", () => {
31
+ expect(automationOf({ id: `witty-harbor-pknz` })).toBeUndefined();
32
+ expect(automationOf({ id: `home-1a2b3c4d5e6f7a8b` })).toBeUndefined();
33
+ expect(automationOf({ id: `a-` })).toBeUndefined();
34
+ });
35
+ });
36
+
37
+ describe("stackHeads", () => {
38
+ test("picks each automation's latest run, a running turn by its start so two at work never trade places", () => {
39
+ const heads = stackHeads(
40
+ [run(`r1`, { updatedAt: 100 }), run(`r2`, { updatedAt: 300 }), run(`r3`, { status: `running`, startedAt: 200, updatedAt: 900 })],
41
+ automationOf,
42
+ );
43
+ expect(heads.get(`x-follow`)?.id).toBe(`r2`);
44
+ const working = stackHeads([run(`old`, { status: `running`, startedAt: 100, updatedAt: 990 }), run(`new`, { status: `running`, startedAt: 200, updatedAt: 210 })], automationOf);
45
+ expect(working.get(`x-follow`)?.id).toBe(`new`);
46
+ });
47
+
48
+ test("keeps out whatever the key names no automation for", () => {
49
+ expect(stackHeads([person(`p`, { updatedAt: 5 })], automationOf).size).toBe(0);
50
+ });
51
+ });
52
+
53
+ // The rail badge counts an automation's runs as the board draws them: one card, its latest run's.
54
+ describe("attentionCards over an automation's runs", () => {
55
+ const failed = { status: ERROR };
56
+
57
+ test("counts nothing for earlier failures a later run has moved past", () => {
58
+ const fleet = [run(`r1`, { ...failed, updatedAt: 100 }), run(`r2`, { ...failed, updatedAt: 200 }), run(`r3`, { updatedAt: 300 })];
59
+ expect(attentionCards(fleet)).toBe(0);
60
+ });
61
+
62
+ test("counts the stack once when its latest run failed, however many before it did", () => {
63
+ const fleet = [run(`r1`, { ...failed, updatedAt: 100 }), run(`r2`, { ...failed, updatedAt: 200 }), run(`r3`, { ...failed, updatedAt: 300 })];
64
+ expect(attentionCards(fleet)).toBe(1);
65
+ expect(attentionCalls(fleet).map((call) => call.card)).toEqual([`r3`]);
66
+ });
67
+
68
+ test("counts an earlier run asking what only the reader can give, through the stack's card", () => {
69
+ const fleet = [run(`r1`, { status: `awaiting`, attention: { ...none, credential: true }, updatedAt: 100 }), run(`r2`, { updatedAt: 300 })];
70
+ expect(attentionCalls(fleet)).toEqual([{ id: `r1`, card: `r2` }]);
71
+ });
72
+
73
+ test("keeps a person's own work out of any stack", () => {
74
+ const fleet = [run(`r1`, { updatedAt: 300 }), person(`mine`, { ...failed, updatedAt: 100 })];
75
+ expect(attentionCalls(fleet)).toEqual([{ id: `mine`, card: `mine` }]);
76
+ });
77
+
78
+ test("lets a run's own child ride its parent run, and so the stack", () => {
79
+ const fleet = [
80
+ run(`r1`, { updatedAt: 100 }),
81
+ run(`r2`, { updatedAt: 300 }),
82
+ person(`helper`, { startedBy: `agent:r1`, status: `awaiting`, attention: { ...none, permission: true }, updatedAt: 150 }),
83
+ ];
84
+ expect(attentionCalls(fleet)).toEqual([{ id: `helper`, card: `r2` }]);
85
+ });
86
+ });
@@ -0,0 +1,48 @@
1
+ // ONE AUTOMATION, ONE CARD. Every fire of an automation that starts an agent opens a conversation of its own, so an
2
+ // automation on a ten-minute clock fills the board with a card per run, each named afresh by the naming pass, until a
3
+ // person's own work is lost among a hundred cards telling one story. So the runs of one automation ride under one card,
4
+ // as a spawned child rides under its parent (agent-lanes.ts attentionCalls, and the editor's childFold.ts, which draws
5
+ // it): the card of its latest run, with the earlier runs as rows in its tray. Which card that is, and which runs ride
6
+ // it, is decided here once, so the board and the daemon's count of what needs the person (`agents.attention`) agree.
7
+
8
+ // Enough of a conversation to say which automation's run it is and when it last moved; a FleetAgent, a roster
9
+ // AgentSummary and a test literal all fit.
10
+ export interface StackMember {
11
+ readonly id: string;
12
+ readonly origin?: { readonly automationId: string } | undefined;
13
+ readonly startedAt?: number | undefined;
14
+ readonly updatedAt: number;
15
+ }
16
+
17
+ // How the daemon names a conversation an automation minted (automations/fire/fire-sessions.ts mintConversationId): `a-`,
18
+ // the automation's id cut to 40 characters, `-`, then a base36 stamp with no `-` in it. Read only for a run with no
19
+ // origin, which is every run a schedule fired before the daemon recorded one, so the board stacks those too.
20
+ const MINTED = /^a-(.+)-[0-9a-z]+$/;
21
+
22
+ // The automation whose run this conversation is, or undefined for one a person or another agent started.
23
+ export const automationOf = (agent: Pick<StackMember, "id" | "origin">): string | undefined =>
24
+ agent.origin?.automationId ?? MINTED.exec(agent.id)?.[1];
25
+
26
+ // When a run last moved, as a stack orders its runs: a running turn's start, which holds still for the turn's life so
27
+ // two runs at work at once never trade places on every tick, else its last move.
28
+ export const movedAt = (run: Pick<StackMember, "startedAt" | "updatedAt">): number => run.startedAt ?? run.updatedAt;
29
+
30
+ // Whether `a` is the later of two runs; the id breaks a tie, so every surface picks the same one.
31
+ const later = (a: StackMember, b: StackMember): boolean => movedAt(a) > movedAt(b) || (movedAt(a) === movedAt(b) && a.id > b.id);
32
+
33
+ // The latest run of each automation among `agents`, by the key `stackOf` gives it (the automation, and on a board that
34
+ // holds several sandboxes' cards, the sandbox too); undefined skips a conversation that is no automation's run.
35
+ export const stackHeads = <T extends StackMember>(agents: Iterable<T>, stackOf: (agent: T) => string | undefined): ReadonlyMap<string, T> => {
36
+ const heads = new Map<string, T>();
37
+ for (const agent of agents) {
38
+ const key = stackOf(agent);
39
+ if (key === undefined) {
40
+ continue;
41
+ }
42
+ const head = heads.get(key);
43
+ if (head === undefined || later(agent, head)) {
44
+ heads.set(key, agent);
45
+ }
46
+ }
47
+ return heads;
48
+ };
@@ -23,7 +23,7 @@ const LEDGER: Record<keyof AgentCapabilities, Backing> = {
23
23
  isolation: "enforced",
24
24
  // system-prompt.ts composes standing instructions against this: replace, append, or a user-message note.
25
25
  instructions: "enforced",
26
- // turn-plan.ts notes the catalogue only for "prompt"; "native" runtimes use their own loader instead.
26
+ // agent/run/decide/turn-skills.ts names in the opening note only the skills whose origin the loader does not read.
27
27
  skillDiscovery: "enforced",
28
28
  // turn-plan.ts plans jsExecution only where "js" is declared; without it no Code tool is mounted.
29
29
  execution: "enforced",
@@ -41,6 +41,9 @@ const LEDGER: Record<keyof AgentCapabilities, Backing> = {
41
41
  // automation ladder passes such rungs over, and agent/tools/tool-allowlist.ts refuses every off-list call on the
42
42
  // Claude Code loop.
43
43
  toolAllowlist: "enforced",
44
+ // runtimes/decorators/ending-checks.ts reads a "turn-end" loop's edited files as its turn ends and says the
45
+ // owner's per-edit checks back with the repositories' turn checks; an "after-edit" loop's own hook said them.
46
+ editChecks: "enforced",
44
47
 
45
48
  // Descriptive: true of the runtime but nothing consults these; wiring one up moves it to enforced.
46
49
  questions: "descriptive",
@@ -0,0 +1,20 @@
1
+ import type { WorkspaceModule } from "../schemas/git/git.js";
2
+
3
+ // Which package a repository-relative path lives in, the one rule every reader buckets files by: the review panels
4
+ // group a change list with it, and the sandbox names the packages a conversation's work falls in for its card, so the
5
+ // two can never put one file in different packages.
6
+
7
+ // Longest matching module dir wins, so a nested package claims its own files before its parent.
8
+ // dir "" claims what nothing deeper claims; the trailing separator keeps `_apps/webx` from matching `_editor/web`.
9
+ export const moduleOf = (path: string, modules: readonly WorkspaceModule[]): WorkspaceModule | undefined => {
10
+ let best: WorkspaceModule | undefined;
11
+ for (const candidate of modules) {
12
+ if (candidate.dir !== `` && !path.startsWith(`${candidate.dir}/`)) {
13
+ continue;
14
+ }
15
+ if (best === undefined || candidate.dir.length > best.dir.length) {
16
+ best = candidate;
17
+ }
18
+ }
19
+ return best;
20
+ };
@@ -155,9 +155,13 @@ test("cursor holds every mode, a tool list, the terminal panel and its own skill
155
155
  const cursor = capabilitiesOf("cursor", "native");
156
156
  expect(cursor.permissions).toBe("modes");
157
157
  expect(cursor.terminals).toBe(true);
158
- expect(cursor.skillDiscovery).toBe("native");
158
+ expect(cursor.skillDiscovery).toEqual({ workspace: true, builtIn: false, kit: true });
159
159
  expect(cursor.toolAllowlist).toBe(true);
160
- expect(limitationsOf(cursor)).toEqual(["no plugins", "secrets masked in shell output only, not in files the agent reads"]);
160
+ expect(limitationsOf(cursor)).toEqual([
161
+ "no plugins",
162
+ "secrets masked in shell output only, not in files the agent reads",
163
+ "edit checks said as the turn ends, not after each edit",
164
+ ]);
161
165
  });
162
166
 
163
167
  // Devin runs the ACP floor under its own name: what the floor gained (approvals, questions) it has too, and the effort
@@ -421,6 +421,13 @@ export const MINTED_PROVIDERS: readonly MintedProvider[] = PROVIDER_SPECS.filter
421
421
  (spec): spec is Extract<Spec, { auth: { kind: "minted" } }> => spec.auth.kind === "minted",
422
422
  ).map((spec) => spec.id);
423
423
 
424
+ // OpenCode's id for Gemini through the translator; not "google", OpenCode's own API-key provider, which bypasses the fleet.
425
+ export const OPENCODE_GEMINI_PROVIDER = "intentic-gemini";
426
+
427
+ // The provider an id names everywhere outside OpenCode's own config. A model running there sees the alias above as its
428
+ // provider and repeats it when it names one (a subagent's spawn or move), so the doors that take such a name read it here.
429
+ export const canonicalProvider = (provider: string): string => (provider === OPENCODE_GEMINI_PROVIDER ? "gemini" : provider);
430
+
424
431
  // This provider's CLIProxyAPI id, where it has one; not always ours (grok/xai, gemini/antigravity).
425
432
  export const cliProxyIdOf = (provider: string): string | undefined => {
426
433
  const auth = providerSpec(provider)?.auth;
@@ -18,8 +18,13 @@ export interface PersonaShelves {
18
18
  readonly mcp?: readonly string[] | undefined;
19
19
  }
20
20
 
21
- /** The kinds that are someone signed in: a site account, and the identity whose browser holds every account born of it. */
22
- export const isSignedInAccount = (kind: string): boolean => kind === "browser" || kind === "identity";
21
+ /**
22
+ * The kinds that are someone signed in: a site account, the identity whose browser holds every account born of it, and
23
+ * the owner's own browser (its extension), which holds every session they have. The last one was missing until
24
+ * 2026-10-25, and it fell through to "passes untouched": an X-only persona's unattended run reached the owner's Brave,
25
+ * and an unpinned wake that lost every sandbox account kept it.
26
+ */
27
+ export const isSignedInAccount = (kind: string): boolean => kind === "browser" || kind === "identity" || kind === "webext";
23
28
 
24
29
  const listed = (list: readonly string[] | undefined, id: string): boolean => list === undefined || list.includes(id);
25
30
 
@@ -31,9 +36,11 @@ const listed = (list: readonly string[] | undefined, id: string): boolean => lis
31
36
  export const personaAllowsCapability = (capability: CapabilityRef, accounts: readonly string[], shelves: PersonaShelves): boolean => {
32
37
  switch (capability.kind) {
33
38
  // The signed-in browsers: the persona's own `capabilities` list, and the field that predates the shelves. Granting
34
- // an account (`reddit-work`) grants its hands; granting the identity itself grants the someone.
39
+ // an account (`reddit-work`) grants its hands; granting the identity itself grants the someone. The owner's own
40
+ // browser is named the same way: a persona reaches it only when its list says so.
35
41
  case "browser":
36
42
  case "identity":
43
+ case "webext":
37
44
  return accounts.includes(capability.id);
38
45
  // Connector credentials reach the shell; an ungranted id keeps its env vars out of the turn entirely.
39
46
  case "cli":
@@ -247,7 +247,7 @@ export const AgentSummarySchema = z.object({
247
247
  .string()
248
248
  .optional()
249
249
  .describe(
250
- "One word for the kind of work the naming pass read in it (fix, audit, redesign). Never part of the displayed name: a board tints and glyphs a card by it. Absent for a title nothing named an action for.",
250
+ "One word for the kind of work the naming pass read in it (fix, audit, redesign). Never part of the displayed name. Absent for a title nothing named an action for.",
251
251
  ),
252
252
  status: AgentStatusSchema.describe(
253
253
  "What it is doing. Stopping and stopped are the two halves of somebody pressing stop, because a cancel is not instant; dismissing is the same window for a question waved away, which ends the turn too but owes the user nothing; resuming means the sandbox is already putting right whatever killed the turn; landing means its work is being carried into the workspace right now, and nothing may act on its branch until that settles.",
@@ -554,6 +554,13 @@ export const AgentSummarySchema = z.object({
554
554
  })
555
555
  .optional()
556
556
  .describe("Everything it has written, measured from where it started. Independent of how much has been merged."),
557
+ // Read off the same changes `diff` counts, so the two can't disagree.
558
+ modules: z
559
+ .array(z.string())
560
+ .optional()
561
+ .describe(
562
+ "The packages its changes fall in, most-changed first, at most three: each the folder name of the nearest manifest (package.json, Cargo.toml, pyproject.toml, go.mod) above a changed file, or the repository's name for a file outside every package. Absent until it has changed something.",
563
+ ),
557
564
  // How much of what this agent landed is still in the working tree, since a discard after landing is invisible to
558
565
  // commit-based readings; absent is the steady state and carries no line.
559
566
  landedPresence: z
@@ -0,0 +1,35 @@
1
+ import { z } from "zod";
2
+
3
+ // What the daemon emits as the fleet works, for a code chore to react to; no token or route, since only the daemon
4
+ // reads these.
5
+ // turn.settled: fires once per isolated turn regardless of outcome, while the diff is still on screen
6
+ // agent.landed: fires only once work reaches the main tree, including a later manual Land
7
+ // deps.broken/deps.fixed: retired with the check that ran after every land (2026-09-27). Nothing emits them any more;
8
+ // they stay so an automation written for them still reads, and it never fires. Main's failing CI is the `ci` listener's.
9
+ export const WorkspaceEventKindSchema = z.enum(["turn.settled", "agent.landed", "deps.broken", "deps.fixed"]);
10
+ export type WorkspaceEventKind = z.infer<typeof WorkspaceEventKindSchema>;
11
+ // Payload of a workspace-triggered wake, delivered as JSON in $AUTOMATION_PAYLOAD and appended to the prompt.
12
+ // `repos[].from` is each repo's state before the turn; the span runs to the working tree, not a commit, so an errored
13
+ // turn's uncommitted work still shows.
14
+ export const WorkspaceEventSchema = z.object({
15
+ event: WorkspaceEventKindSchema,
16
+ agentId: z.string(),
17
+ title: z.string().optional(),
18
+ branch: z.string(),
19
+ // `ready` is a clean turn held on the branch (auto-land off): the moment right before a deliberate Land, when a
20
+ // pre-land review wants to run.
21
+ outcome: z.enum(["landed", "conflict", "ready", "idle", "error"]),
22
+ repos: z.array(z.object({ repo: z.string(), from: z.string(), dir: z.string() })),
23
+ // Present only for `deps.*` events: which project broke, the command and exit code, a bounded log tail, and
24
+ // `attempt` counting consecutive failures since the last pass.
25
+ deps: z
26
+ .object({
27
+ project: z.string(),
28
+ command: z.string(),
29
+ exitCode: z.number(),
30
+ attempt: z.number(),
31
+ logTail: z.string(),
32
+ })
33
+ .optional(),
34
+ });
35
+ export type WorkspaceEvent = z.infer<typeof WorkspaceEventSchema>;
@@ -4,6 +4,8 @@ import { AgentOriginSchema, ModelPinSchema } from "./agent.js";
4
4
  import { entryId } from "./internal.js";
5
5
  import { IssuesConfigSchema } from "./issues.js";
6
6
  import { ZoneSchema } from "../time/zone.js";
7
+ import { WorkspaceEventKindSchema } from "./automation-events.js";
8
+ export { WorkspaceEventKindSchema, WorkspaceEventSchema, type WorkspaceEventKind, type WorkspaceEvent } from "./automation-events.js";
7
9
  // An automation wakes the agent: the daemon fires each enabled one on its trigger, runs the optional guard command
8
10
  // (non-zero exit skips the wake), then runs one turn with the prompt. The manifest is user config; run history is
9
11
  // daemon-recorded.
@@ -23,39 +25,6 @@ import { ZoneSchema } from "../time/zone.js";
23
25
  // pushes one branch per agent
24
26
  // workspace: fires from the sandbox's own codebase (WorkspaceEventKindSchema)
25
27
 
26
- // What the daemon emits as the fleet works, for a code chore to react to; no token or route, since only the daemon
27
- // reads these.
28
- // turn.settled: fires once per isolated turn regardless of outcome, while the diff is still on screen
29
- // agent.landed: fires only once work reaches the main tree, including a later manual Land
30
- // deps.broken/deps.fixed: retired with the check that ran after every land (2026-09-27). Nothing emits them any more;
31
- // they stay so an automation written for them still reads, and it never fires. Main's failing CI is the `ci` listener's.
32
- export const WorkspaceEventKindSchema = z.enum(["turn.settled", "agent.landed", "deps.broken", "deps.fixed"]);
33
- export type WorkspaceEventKind = z.infer<typeof WorkspaceEventKindSchema>;
34
- // Payload of a workspace-triggered wake, delivered as JSON in $AUTOMATION_PAYLOAD and appended to the prompt.
35
- // `repos[].from` is each repo's state before the turn; the span runs to the working tree, not a commit, so an errored
36
- // turn's uncommitted work still shows.
37
- export const WorkspaceEventSchema = z.object({
38
- event: WorkspaceEventKindSchema,
39
- agentId: z.string(),
40
- title: z.string().optional(),
41
- branch: z.string(),
42
- // `ready` is a clean turn held on the branch (auto-land off): the moment right before a deliberate Land, when a
43
- // pre-land review wants to run.
44
- outcome: z.enum(["landed", "conflict", "ready", "idle", "error"]),
45
- repos: z.array(z.object({ repo: z.string(), from: z.string(), dir: z.string() })),
46
- // Present only for `deps.*` events: which project broke, the command and exit code, a bounded log tail, and
47
- // `attempt` counting consecutive failures since the last pass.
48
- deps: z
49
- .object({
50
- project: z.string(),
51
- command: z.string(),
52
- exitCode: z.number(),
53
- attempt: z.number(),
54
- logTail: z.string(),
55
- })
56
- .optional(),
57
- });
58
- export type WorkspaceEvent = z.infer<typeof WorkspaceEventSchema>;
59
28
  export const TriggerSchema = z.discriminatedUnion("kind", [
60
29
  z.object({
61
30
  kind: z.literal("schedule").describe("On a clock."),
@@ -446,6 +415,16 @@ export const AutomationSchema = z.object({
446
415
  .boolean()
447
416
  .optional()
448
417
  .describe("This automation is a maintenance job, which is what files it under chores rather than among ordinary automations."),
418
+ // Pure filing, like `chore`: the daemon never reads it. Absent, the panel files it by its persona instead, so a set
419
+ // of jobs one role runs reads as one shelf without anybody naming it.
420
+ group: z
421
+ .string()
422
+ .min(1)
423
+ .max(40)
424
+ .optional()
425
+ .describe(
426
+ "Which shelf of the Automations panel it is filed on, like 'Marketing' or 'Releases'. Automations naming the same group are listed together. Absent files it under its persona, or with the code chores.",
427
+ ),
449
428
  enabled: z.boolean().describe("Whether it fires at all."),
450
429
  });
451
430
  export type Automation = z.infer<typeof AutomationSchema>;
@@ -1,21 +1,9 @@
1
1
  import { z } from "zod";
2
2
 
3
- // Closed set of command names; the daemon builds argv from the name alone (hosts/device-commands.ts). Never a free-text
4
- // command: the same socket carries `run_command`, which would grant a shell on the user's machine.
5
- // `sync-unpair` runs the machine's own `sync uninstall --sandbox` (terminates both Mutagen sessions, drops the pairing,
6
- // self-revokes enrollment); revoking from the sandbox side is a different route.
7
- // `dev-restart`, `dev-rebuild` and `sync-install` are the three whose argv the daemon fills from what only it knows — the
8
- // dev checkout on that machine, a freshly minted pairing token — which is also why none takes a path or a token from the
9
- // caller. The two dev commands are also why a machine-side op is the wrong shape for them: this door carries a COMMAND
10
- // STRING to a tool every released agent already has, so a sandbox can drive a machine whose agent predates the feature.
11
- // `dev-rebuild-log` is the read side of `dev-rebuild`: the build is detached out there, so its log is the only place its
12
- // progress exists, and polling this is what turns a fired-and-forgotten command into something with a progress bar.
13
- // `sync-clean` removes the build output left in directories the sandbox has deleted, which is what stops those
14
- // deletions from ever landing. It deletes only content the session already ignores, so it is the one command here whose
15
- // worst outcome is a rebuild — which is why it is a button and not a turn.
16
- // `mirror-ignore`/`mirror-unignore` are `mirror-off`'s per-port form, and the reason they exist: a port number that is
17
- // permanently taken on one machine's localhost has no answer in an all-or-nothing switch, so the choice lives where the
18
- // conflict does — one port, one device, durable — instead of muting every port the pairing serves.
3
+ // Closed names let the daemon build argv without granting arbitrary shell access; dev/sync commands never accept caller paths or tokens.
4
+ // Use existing run_command for older-agent compatibility; poll dev-rebuild-log because rebuilds are detached.
5
+ // sync-unpair terminates sessions/revokes enrollment; sync-clean removes ignored build residue only.
6
+ // mirror-ignore/unignore persists per-device port choices without muting all mirrored ports.
19
7
  export const DeviceCommandSchema = z.enum([
20
8
  "mirror-off",
21
9
  "mirror-on",
@@ -55,7 +43,7 @@ export const DeviceLocalDirSchema = z
55
43
  .string()
56
44
  .min(1)
57
45
  .max(4096)
58
- // The curly quotes as escapes: bun's transpiler rewrites a literal one in a regex to its escape and tsgo keeps it,
46
+ // The curly quotes as escapes: bun's transpiler rewrites a literal one in a regex to its escape and tsc keeps it,
59
47
  // so spelled literally the pattern reads differently from source and from dist, and contract.lock.json can match
60
48
  // only one of them.
61
49
  .regex(/^(?:~|\/|[A-Za-z]:[\\/])[^"'`$;|&\n\r\u201C\u201D\u201E\u2018\u2019\u201A\u201B]*$/);