@intentic/sandbox-contract 1.167.0 → 1.169.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 (68) hide show
  1. package/dist/agent-catalog.d.ts +20 -2
  2. package/dist/agent-catalog.d.ts.map +1 -1
  3. package/dist/agent-catalog.js +80 -4
  4. package/dist/agent-catalog.js.map +1 -1
  5. package/dist/contracts/agent.contract.d.ts +36 -8
  6. package/dist/contracts/agent.contract.d.ts.map +1 -1
  7. package/dist/contracts/agent.contract.js +1 -2
  8. package/dist/contracts/agent.contract.js.map +1 -1
  9. package/dist/contracts/agents.contract.d.ts +37 -39
  10. package/dist/contracts/agents.contract.d.ts.map +1 -1
  11. package/dist/contracts/extensions.contract.d.ts +4 -0
  12. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  13. package/dist/contracts/prepush.contract.d.ts +25 -0
  14. package/dist/contracts/prepush.contract.d.ts.map +1 -0
  15. package/dist/contracts/prepush.contract.js +8 -0
  16. package/dist/contracts/prepush.contract.js.map +1 -0
  17. package/dist/contracts/sessions.contract.d.ts +1 -39
  18. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  19. package/dist/contracts/settings.contract.d.ts +8 -10
  20. package/dist/contracts/settings.contract.d.ts.map +1 -1
  21. package/dist/contracts/system.contract.d.ts +56 -0
  22. package/dist/contracts/system.contract.d.ts.map +1 -1
  23. package/dist/contracts/system.contract.js +7 -2
  24. package/dist/contracts/system.contract.js.map +1 -1
  25. package/dist/contracts/translator.contract.d.ts +36 -0
  26. package/dist/contracts/translator.contract.d.ts.map +1 -1
  27. package/dist/events.d.ts +88 -159
  28. package/dist/events.d.ts.map +1 -1
  29. package/dist/events.js +37 -4
  30. package/dist/events.js.map +1 -1
  31. package/dist/index.d.ts +218 -157
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +4 -3
  34. package/dist/index.js.map +1 -1
  35. package/dist/quick-model.d.ts +1 -0
  36. package/dist/quick-model.d.ts.map +1 -1
  37. package/dist/quick-model.js +1 -1
  38. package/dist/quick-model.js.map +1 -1
  39. package/dist/schemas.d.ts +176 -69
  40. package/dist/schemas.d.ts.map +1 -1
  41. package/dist/schemas.js +48 -40
  42. package/dist/schemas.js.map +1 -1
  43. package/dist/title.d.ts.map +1 -1
  44. package/dist/title.js.map +1 -1
  45. package/dist/workspace-state.d.ts +9 -0
  46. package/dist/workspace-state.d.ts.map +1 -0
  47. package/dist/workspace-state.js +61 -0
  48. package/dist/workspace-state.js.map +1 -0
  49. package/package.json +2 -2
  50. package/src/agent-catalog.test.ts +118 -0
  51. package/src/agent-catalog.ts +179 -15
  52. package/src/contracts/agent.contract.ts +1 -14
  53. package/src/contracts/prepush.contract.ts +16 -0
  54. package/src/contracts/system.contract.ts +15 -1
  55. package/src/events.test.ts +32 -0
  56. package/src/events.ts +105 -27
  57. package/src/index.ts +4 -3
  58. package/src/quick-model.ts +4 -2
  59. package/src/schemas.test.ts +13 -18
  60. package/src/schemas.ts +179 -152
  61. package/src/title.ts +6 -2
  62. package/src/workspace-state.test.ts +129 -0
  63. package/src/workspace-state.ts +150 -0
  64. package/dist/contracts/gate.contract.d.ts +0 -47
  65. package/dist/contracts/gate.contract.d.ts.map +0 -1
  66. package/dist/contracts/gate.contract.js +0 -9
  67. package/dist/contracts/gate.contract.js.map +0 -1
  68. package/src/contracts/gate.contract.ts +0 -19
package/src/schemas.ts CHANGED
@@ -262,6 +262,14 @@ export const AgentSummarySchema = z.object({
262
262
  // Completed turns and lifetime tool calls — the card's msgs/tools counters.
263
263
  turns: z.number().optional(),
264
264
  toolUses: z.number().optional(),
265
+ /* The agents THIS agent started (SubagentSessionSchema), live and lifetime. Absent ⇒ it has never delegated,
266
+ * which is most agents — so the card's chip appears on content rather than reading "0" down the board.
267
+ *
268
+ * It earns a place on a card because a fleet card is the answer to "what is this agent up to", and an agent
269
+ * running five children looked exactly like an agent running none: the work was real, the spend was real, and
270
+ * the board said nothing. The tokens are NOT folded into the parent's cost — a child's spend is its own, and
271
+ * the Subagents area is where it is attributed. */
272
+ subagents: z.object({ running: z.number(), total: z.number() }).optional(),
265
273
  // The agent's cumulative output (base → branch tip across every repo), refreshed on each land —
266
274
  // the card's "12 files · +412 −96" readout. Independent of what has landed.
267
275
  diff: z.object({ files: z.number(), insertions: z.number(), deletions: z.number() }).optional(),
@@ -383,9 +391,52 @@ export const AgentLandSchema = z.object({ id: z.string().min(1), mode: LandModeS
383
391
  export const KeyedProviderSchema = z.enum(["codex", "grok", "kimi", "gemini"]);
384
392
  export type KeyedProvider = z.infer<typeof KeyedProviderSchema>;
385
393
 
394
+ // ---- plan-limit usage ----
395
+ // Declared ABOVE both account shapes because both carry it: headroom is one idea in this product, not a Claude
396
+ // idea that other providers imitate. A native account (OauthAccount) and a routed subscription
397
+ // (TranslatorAccount) differ in who holds the credential and how the reading is taken — never in what a
398
+ // reading IS — so every surface that draws a percentage reads this one type and no other.
399
+
400
+ // One plan-limit pool. `kind` is the provider's own key ('five_hour' | 'seven_day' | 'seven_day_opus' |
401
+ // 'seven_day_sonnet' | 'model:Fable' | …) rather than an enum we'd have to keep in step with the provider: an
402
+ // unrecognised pool is shown under its raw key, which is far better than being silently folded into a
403
+ // neighbour. `label` is the provider's OWN display name where it supplies one (the per-model buckets do) — it
404
+ // wins over anything we'd infer, because the model names in a plan's limits are the provider's to rename.
405
+ // `resetsAt` is epoch SECONDS (matching the SDK's frame).
406
+ export const UsageWindowSchema = z.object({
407
+ kind: z.string(),
408
+ label: z.string().optional(),
409
+ utilization: z.number(), // 0-100
410
+ resetsAt: z.number().optional(),
411
+ });
412
+ export type UsageWindow = z.infer<typeof UsageWindowSchema>;
413
+
414
+ // An account's headroom: EVERY window the provider reports, read together, plus when the reading was taken.
415
+ // All of them, not the binding one, because "which pool is binding" changes between turns and a reader
416
+ // comparing accounts needs the same pools on every row. How the reading is TAKEN is per provider and stops at
417
+ // the daemon's readers: Claude's rides the turn's own stream, ChatGPT's and Google's are pulled through
418
+ // CLIProxyAPI's credential-scoped management call. All of them are control requests, so none costs tokens.
419
+ //
420
+ // Within one window utilization only climbs, so an un-reset window stays a valid FLOOR however old it is; past
421
+ // its `resetsAt` it describes a pool that no longer exists and the store drops it. `measuredAt` is epoch MS
422
+ // (matching connectedAt) — deliberately a different unit from the windows' seconds.
423
+ export const AccountUsageSchema = z.object({
424
+ windows: z.array(UsageWindowSchema),
425
+ measuredAt: z.number(),
426
+ });
427
+ export type AccountUsage = z.infer<typeof AccountUsageSchema>;
428
+
386
429
  // One connected subscription in the translator. `name` is CLIProxyAPI's auth-file name — the stable store key a
387
430
  // disconnect addresses — and `label` the sign-in identity it reported (the account email, else the file name).
388
- export const TranslatorAccountSchema = z.object({ name: z.string(), label: z.string() });
431
+ export const TranslatorAccountSchema = z.object({
432
+ name: z.string(),
433
+ label: z.string(),
434
+ // The same headroom an OauthAccount carries, on the same field, for the same reason: the account rows are
435
+ // one list to the reader. Optional because a provider whose quota this sandbox cannot read (Grok, Kimi) —
436
+ // or one that did not answer — must still render as the connected account it is, with a dot instead of a
437
+ // ring.
438
+ usage: AccountUsageSchema.optional(),
439
+ });
389
440
  export type TranslatorAccount = z.infer<typeof TranslatorAccountSchema>;
390
441
  // Which routed-provider subscriptions are connected in the translator, per provider — a LIST per provider, not
391
442
  // a flag: CLIProxyAPI holds any number of auth files per provider side by side and balances requests across
@@ -453,14 +504,8 @@ export const SteerSchema = z
453
504
  // True cancel for the conversation's in-flight turn — aborts the agent daemon-side, unlike closing the
454
505
  // /agent fetch (which sends no cancel frame).
455
506
  export const StopTurnSchema = z.object({ conversationId: z.string().min(1) });
456
- // Fire the conversation's remembered usage-limit resume NOW instead of waiting out the reset. `account`
457
- // points the re-run at a different connected account of the same provider — the "resume on another account"
458
- // action a spent allowance offers when the sandbox holds more than one; omitted, the turn re-runs on
459
- // whatever served it (a plain "try again now"). NOT_FOUND when nothing is pending (the failure was already
460
- // superseded by a fresh turn, or the daemon restarted).
461
- export const ResumeLimitSchema = z.object({ conversationId: z.string().min(1), account: z.string().min(1).optional() });
462
-
463
- // ---- claude subscription usage ----
507
+
508
+ // ---- claude rate-limit gate ----
464
509
  // The GATE signal: whether the provider is letting turns through right now, and — when it is refusing — which
465
510
  // window is binding and when it lifts. This is the SDK's rate_limit_event, mapped one-to-one, and it is only
466
511
  // ever about the CURRENT moment. It is deliberately NOT the thing the headroom displays read: the event names a
@@ -474,34 +519,6 @@ export const RateLimitInfoSchema = z.object({
474
519
  });
475
520
  export type RateLimitInfo = z.infer<typeof RateLimitInfoSchema>;
476
521
 
477
- // One plan-limit pool. `kind` is the provider's own key ('five_hour' | 'seven_day' | 'seven_day_opus' |
478
- // 'seven_day_sonnet' | 'model:Fable' | …) rather than an enum we'd have to keep in step with the provider: an
479
- // unrecognised pool is shown under its raw key, which is far better than being silently folded into a
480
- // neighbour. `label` is the provider's OWN display name where it supplies one (the per-model buckets do) — it
481
- // wins over anything we'd infer, because the model names in a plan's limits are the provider's to rename.
482
- // `resetsAt` is epoch SECONDS (matching the SDK's frame).
483
- export const UsageWindowSchema = z.object({
484
- kind: z.string(),
485
- label: z.string().optional(),
486
- utilization: z.number(), // 0-100
487
- resetsAt: z.number().optional(),
488
- });
489
- export type UsageWindow = z.infer<typeof UsageWindowSchema>;
490
-
491
- // An account's headroom: EVERY window the provider reports, read together, plus when the reading was taken.
492
- // All of them, not the binding one, because "which pool is binding" changes between turns and a reader
493
- // comparing accounts needs the same pools on every row. Sourced from the CLI's own usage endpoint at turn end
494
- // (see claudeUsageWindows) — a control request, so it costs no tokens.
495
- //
496
- // Within one window utilization only climbs, so an un-reset window stays a valid FLOOR however old it is; past
497
- // its `resetsAt` it describes a pool that no longer exists and the store drops it. `measuredAt` is epoch MS
498
- // (matching connectedAt) — deliberately a different unit from the windows' seconds.
499
- export const AccountUsageSchema = z.object({
500
- windows: z.array(UsageWindowSchema),
501
- measuredAt: z.number(),
502
- });
503
- export type AccountUsage = z.infer<typeof AccountUsageSchema>;
504
-
505
522
  // ---- provider oauth ----
506
523
  // Claude uses the PKCE authorize-URL + paste-back handshake (start → exchange). Codex uses OpenAI's device-code
507
524
  // flow (start → poll): the browser signs in at verificationUri and enters userCode; the daemon polls until done.
@@ -528,8 +545,9 @@ export const OauthAccountSchema = z.object({
528
545
  needsReauth: z.boolean().optional(),
529
546
  detail: z.string().optional(),
530
547
  // The account's last known subscription-usage snapshot, so the picker can show what's left on each account
531
- // before the user commits a turn to one. Claude-only (it is the sole provider whose stream reports a usage
532
- // window) and absent until that account has run a turn an unmeasured account reads as unknown, never 0%.
548
+ // before the user commits a turn to one. Absent until a reading exists for it an unmeasured account reads
549
+ // as unknown, never 0%. Claude is the provider that fills it here, because its stream reports the windows;
550
+ // the routed subscriptions carry the identical field on TranslatorAccount, filled by a pulled reading.
533
551
  usage: AccountUsageSchema.optional(),
534
552
  });
535
553
  export type OauthAccount = z.infer<typeof OauthAccountSchema>;
@@ -669,11 +687,6 @@ export const BuiltinPromptSchema = z.object({ base: z.enum(["intentic", "claude"
669
687
  // surface fail to parse the moment a toggle is added — which reaches the user as a page of switches that are
670
688
  // silently dead, not as an error. It also means an older on-disk manifest keeps the owner's other picks rather
671
689
  // than being discarded whole.
672
- // Build-wide kill switch for usage-limit auto-resume. Keep the setting and implementation in place while the
673
- // feature is disabled, but clamp every daemon response and settings write to OFF so a persisted `true` from an
674
- // older build cannot keep spending a newly reset allowance. The web also reads this constant to render the
675
- // control unavailable and to distrust a stale daemon that still reports a scheduled resume.
676
- export const USAGE_LIMIT_AUTO_RESUME_ENABLED: boolean = false;
677
690
 
678
691
  export const SandboxSettingsSchema = z.object({
679
692
  stableSystemPrompt: z.boolean().default(false),
@@ -746,18 +759,12 @@ export const SandboxSettingsSchema = z.object({
746
759
  * automation-opened agents (Discord, webhooks, email) finish turns with no browser in the room — a
747
760
  * browser-held preference could not govern them. Per-agent override: AgentSummarySchema.autoLand. */
748
761
  autoLand: z.boolean().default(true),
749
- // Latent opt-in for re-running a turn after the Claude subscription's usage window resets. It defaults off
750
- // because an unattended retry spends the fresh allowance without the user in the room, and the build-wide
751
- // gate above currently clamps even an older saved opt-in off while the feature is unavailable.
752
- autoResumeOnLimit: z
753
- .boolean()
754
- .default(false)
755
- .overwrite((enabled) => USAGE_LIMIT_AUTO_RESUME_ENABLED && enabled),
756
762
  /* When a turn dies because the MODEL PROVIDER was failing (500/502/503, a 529 at capacity, a dropped
757
763
  * socket), re-run it on an escalating backoff until it goes through or the attempts are spent.
758
764
  *
759
- * Defaults ON, unlike autoResumeOnLimit, and the difference is not an inconsistency: a spent allowance is
760
- * the user's own budget, and resuming into a fresh window spends something they may have been saving. An
765
+ * Defaults ON, and a spent Claude allowance is the counter-example that explains why: that one is the
766
+ * user's own budget, and resuming into a freshly reset window spends something they may have been saving
767
+ * so a usage limit stops the turn and says when it resets, and nothing re-runs it. An
761
768
  * outage resume spends nothing the dead turn had not already committed, resolves in minutes rather than
762
769
  * hours, and — the deciding argument — the turns hurt worst by it are the ones with nobody in the room
763
770
  * (automation wakes, Discord, webhooks), which no browser-held preference could ever rescue. It is the same
@@ -765,8 +772,8 @@ export const SandboxSettingsSchema = z.object({
765
772
  resumeAfterOutage: z.boolean().default(true),
766
773
  /* When the daemon dies under a running turn, re-run that turn once it is back (agent/turn-journal.ts records
767
774
  * every in-flight turn; the boot pass in agent/turn-resume.ts re-runs what survived). ON by default, where
768
- * autoResumeOnLimit is off, and the difference is who broke the turn: a spent allowance is the user's own
769
- * budget, while a restart is usually intentic's OWN doing — the container is recreated on every update,
775
+ * a spent usage limit re-runs nothing, and the difference is who broke the turn: a spent allowance is the
776
+ * user's own budget, while a restart is usually intentic's OWN doing — the container is recreated on every update,
770
777
  * every environment approval and every dev-sandbox.sh swap. Approving the Dockerfile change an agent asked
771
778
  * for must not cost the run that asked for it, and a user who just clicked Approve is in the room expecting
772
779
  * the work to continue, not a second button.
@@ -774,34 +781,26 @@ export const SandboxSettingsSchema = z.object({
774
781
  * OFF still records the interruption: the fleet card reads `interrupted` (see AgentStatusSchema) and an
775
782
  * automation's row shows an `interrupted` run — nothing is re-run, but nothing is silently lost either. */
776
783
  autoResumeOnRestart: z.boolean().default(true),
777
- /* THE LANDING GATE — the check command run over the COMPOSITE of landed work after a landing debounce.
778
- * Empty ⇒ no gate at all, which is the default: only the owner knows what verifies this workspace, and a
779
- * guessed command that fails on a fresh clone would read as the gate finding a bug on its first run.
784
+ /* THE PRE-PUSH CHECK — the command run when the user pushes, before anything leaves the machine.
785
+ * Empty ⇒ no check at all, which is the default: only the owner knows what verifies this workspace, and a
786
+ * guessed command that fails on a fresh clone would read as the check finding a bug on its first run.
780
787
  *
781
788
  * Configuring it is the opt-in, which is why there is no separate enable flag to disagree with it. The
782
- * command runs in the workspace root through `sh -c`, exactly as a terminal would run it (see gate/gate.ts
783
- * for why this is NOT an automation guard: a suite outlives GUARD_TIMEOUT_MS, and a timed-out guard reads
784
- * as "skipped" a silent green over a suite that never finished). */
785
- gateCommand: z.string().max(500).default(""),
786
- /* How long after a land the gate waits before running. A landing burst is the case this exists for: five
787
- * agents finishing within a minute of each other are five lands, and a gate that ran per land would spend
788
- * five suites to answer about four trees nobody will ever push. Every land re-arms the timer, so the run
789
- * happens once, on the tree the user is about to review.
789
+ * command runs in the workspace root through `sh -c`, exactly as a terminal would run it. */
790
+ prepushCommand: z.string().max(500).default(""),
791
+ // Ceiling on one run, after which the child's whole process group is killed and the result is `failed` with
792
+ // `timedOut`. Never a pass: a suite that did not finish has said nothing about the tree, and the one thing
793
+ // this check exists to prevent is a green light nobody earned.
794
+ prepushTimeoutMs: z.number().min(60_000).max(3_600_000).default(900_000),
795
+ /* WHAT THE SUGGESTED FIX SESSION RUNS ON when the check fails `${provider}:${model}` (quickModelKey), and
796
+ * the reasoning effort beside it. Both empty ⇒ whatever the chat composer would have started with, which is
797
+ * the right default precisely because it is the model the user already chose to work with.
790
798
  *
791
- * It counts from the last LAND and nothing else explicitly not "until the fleet is idle". Agents here run
792
- * for hours, so a fleet of twenty with one long runner would never present a quiet moment, and a gate that
793
- * waited for one would only ever fire when clicked (gate/gate.ts). */
794
- gateQuietMs: z.number().min(0).max(600_000).default(20_000),
795
- // Ceiling on one gate run, after which the child is killed and the verdict is `failed` with `timedOut`.
796
- // Never a pass: a suite that did not finish has not said anything about the tree, and the one thing this
797
- // gate exists to prevent is a green light nobody earned.
798
- gateTimeoutMs: z.number().min(60_000).max(3_600_000).default(900_000),
799
- /* Wake a fixer automatically when the gate goes red, instead of only lighting the badge. ON with a
800
- * configured command, unlike the other unattended-spend toggles (autoResumeOnLimit), and the difference is
801
- * that the spend here is the POINT: a red gate whose fix waits for the user to notice has moved the CI
802
- * round-trip into the workspace without removing it from the user's day. One attempt per verdict, so a
803
- * command that fails for a reason no agent can fix costs one turn, not a loop (gate/gate.ts). */
804
- gateAutoFix: z.boolean().default(true),
799
+ * Pinned here rather than resolved cheapest-first like `quickModel`: this is not a one-click helper, it is a
800
+ * turn that has to read a failing suite and repair it, so the choice is the user's and worth stating. The
801
+ * suggestion dialog seeds from these and stays editable the setting decides where the dialog OPENS. */
802
+ prepushFixModel: z.string().default(""),
803
+ prepushFixEffort: z.string().default(""),
805
804
  });
806
805
  export type SandboxSettings = z.infer<typeof SandboxSettingsSchema>;
807
806
 
@@ -2236,95 +2235,51 @@ export type CiRunParam = z.infer<typeof CiRunParamSchema>;
2236
2235
  export const CiFixResponseSchema = z.object({ conversationId: z.string() });
2237
2236
  export type CiFixResponse = z.infer<typeof CiFixResponseSchema>;
2238
2237
 
2239
- /* ---- the landing gate: the workspace's own verdict on the composite of landed work ----
2240
- *
2241
- * WHERE THIS SITS, and why it is not one of the four other places it could:
2242
- *
2243
- * A fleet of 5-20 agents lands work into the main tree as UNCOMMITTED changes (agents/land.ts), the user
2244
- * reviews and commits it by parts, pushes, and CI answers minutes later. This gate front-runs that answer by
2245
- * asking the same question of the same artifact, before the push.
2238
+ /* ---- the pre-push check: the workspace's own answer to "would this push go red" ----
2246
2239
  *
2247
- * NOT inside an agent's turn. An isolated worktree's `node_modules` reads as the MAIN checkout's, so a
2248
- * monorepo's workspace links resolve cross-package imports to /work's sources rather than the worktree's edited
2249
- * ones (agents/worktrees.ts). A suite run in a worktree therefore tests the agent's edits against everyone
2250
- * else's UNEDITED siblings: it invents failures that don't exist and passes changes that break on the
2251
- * composite, and two agents editing one contract each go green alone and red together. The composite is the
2252
- * only honest artifact, and it exists in exactly one place — the main working tree.
2240
+ * WHERE THIS SITS. A fleet of 5-20 agents lands work into the main tree, the user reviews and commits it by
2241
+ * parts, pushes, and CI answers minutes later. The check front-runs that answer at the push itself the last
2242
+ * moment before the work leaves the machine, and the first moment at which what will be pushed is finally
2243
+ * settled.
2253
2244
  *
2254
- * NOT at commit. The user commits BY PARTS, and a suite reads the worktree, not the index so a verdict taken
2255
- * at a partial commit describes a tree that never gets pushed as such. Commit is where a verdict is DISPLAYED
2256
- * (ReviewPanel's badge), computed earlier.
2245
+ * WHY THE PUSH AND NOT THE LAND, which is where this used to run. A post-land verdict is about a tree that
2246
+ * keeps moving: the user commits by parts, another agent lands, an edit arrives so the verdict spent its life
2247
+ * either stale or being recomputed, and needed a content fingerprint, a staleness rule and a badge to say which.
2248
+ * All of that machinery existed to answer a question the push asks for free, because at the push there is
2249
+ * exactly one artifact and the user is standing in front of it waiting.
2257
2250
  *
2258
- * NOT at push. By then HEAD has moved, per-path attribution has expired (agents/origins.ts), and the agents may
2259
- * be archived with their worktrees reclaimed so the fix starts cold, in the same position `/ci/fix` is in.
2260
- * That saves the CI round-trip and none of the context switch.
2261
- *
2262
- * So: after the land, before the staging — the one window where the artifact is what CI will see, attribution
2263
- * is still live, and nobody is waiting on it. */
2251
+ * SO THERE IS NO STORED VERDICT AND NOTHING IS POLLED AT REST. A run exists while it runs, reports to the
2252
+ * dialog that started it, and is gone. Nothing survives a daemon restart because nothing needs to: the next
2253
+ * push asks again. */
2264
2254
 
2265
- /* What the gate has to say about the tree right now.
2255
+ /* Where a run is.
2266
2256
  *
2267
- * idle — no command configured, or nothing has run yet.
2268
- * armed — work landed; the quiet period is counting down (see gateQuietMs).
2257
+ * idle — nothing has run in this daemon's life, or the last run was cleared.
2269
2258
  * running — the check is live. `output` grows as it streams.
2270
- * passed — exited 0 over `fingerprint`.
2271
- * failed — exited non-zero, or was killed by gateTimeoutMs (`timedOut`). The state a fix answers.
2272
- * error — the gate itself could not run: the command was not spawnable. NOT a fix-able failure, because
2273
- * there is nothing wrong with the code — the gate is misconfigured, and saying "tests failed"
2259
+ * passed — exited 0. The push goes.
2260
+ * failed — exited non-zero, or was killed by prepushTimeoutMs (`timedOut`). The state a fix answers.
2261
+ * error — the check could not run at all: the command was not spawnable. NOT a fix-able failure, because
2262
+ * there is nothing wrong with the code — the command is misconfigured, and saying "tests failed"
2274
2263
  * would send an agent hunting a bug that isn't there.
2275
- * cancelled — the user stopped the run, or the tree moved under it.
2264
+ * cancelled — the user stopped the run.
2276
2265
  */
2277
- export const GateStatusSchema = z.enum(["idle", "armed", "running", "passed", "failed", "error", "cancelled"]);
2278
- export type GateStatus = z.infer<typeof GateStatusSchema>;
2279
-
2280
- // An agent whose landed work the failure implicates. `paths` are its attributed files that the check's own
2281
- // output NAMED — empty when the output named none of them, which is the honest shape for a failure that could
2282
- // not be pinpointed (an integration break between two deltas, a suite that prints no paths at all): the agent
2283
- // is listed because its work is in the tree under test, not because anything accused it.
2284
- export const GateAgentSchema = z.object({
2285
- agentId: z.string(),
2286
- title: z.string().optional(),
2287
- provider: AgentProviderSchema.optional(),
2288
- paths: z.array(z.string()),
2289
- });
2290
- export type GateAgent = z.infer<typeof GateAgentSchema>;
2291
-
2292
- // The fix turn one red verdict got. It is a workspace conversation because the composite it must reproduce
2293
- // lives as uncommitted content in the main working tree; it still has the same stable registry identity as any
2294
- // other conversation.
2295
- export const GateFixSchema = z.object({
2296
- startedAt: z.number(),
2297
- conversationId: z.string(),
2298
- // `running` while the turn streams; `done` when it ended cleanly, whatever the re-check then said;
2299
- // `error` when the turn itself failed (a provider outage, no credential), which is worth distinguishing
2300
- // because it is the one case where re-running the fix could still help.
2301
- outcome: z.enum(["running", "done", "error"]),
2302
- detail: z.string().optional(),
2303
- });
2304
- export type GateFix = z.infer<typeof GateFixSchema>;
2266
+ export const PrepushStatusSchema = z.enum(["idle", "running", "passed", "failed", "error", "cancelled"]);
2267
+ export type PrepushStatus = z.infer<typeof PrepushStatusSchema>;
2305
2268
 
2306
- export const GateVerdictSchema = z.object({
2307
- status: GateStatusSchema,
2308
- // The command this verdict ran, echoed rather than read back from settings: a verdict read after the
2269
+ export const PrepushRunSchema = z.object({
2270
+ status: PrepushStatusSchema,
2271
+ // The command this run executed, echoed rather than read back from settings: a result read after the
2309
2272
  // setting changed still has to say what produced it.
2310
2273
  command: z.string(),
2311
2274
  startedAt: z.number().optional(),
2312
2275
  finishedAt: z.number().optional(),
2313
2276
  exitCode: z.number().optional(),
2314
2277
  timedOut: z.boolean().optional(),
2315
- // The check's own output, tail-capped (GATE_OUTPUT_BYTES). The tail, not the head: a suite's verdict and
2278
+ // The check's own output, tail-capped (PREPUSH_OUTPUT_BYTES). The TAIL, not the head: a suite's verdict and
2316
2279
  // its failure summary are at the end, and a head-capped buffer of a chatty build is all progress lines.
2317
2280
  output: z.string(),
2318
- /* WHICH TREE this verdict is about — HEAD plus the shape of every repo's uncommitted content. Recomputed
2319
- * on read: when it no longer matches, the verdict is `stale` and the badge says so instead of asserting a
2320
- * green light over a tree that has since moved. This is what keeps a passed verdict from outliving its
2321
- * subject when the user edits, discards, or commits half of it. */
2322
- fingerprint: z.string(),
2323
- stale: z.boolean(),
2324
- implicated: z.array(GateAgentSchema),
2325
- fix: GateFixSchema.optional(),
2326
2281
  });
2327
- export type GateVerdict = z.infer<typeof GateVerdictSchema>;
2282
+ export type PrepushRun = z.infer<typeof PrepushRunSchema>;
2328
2283
 
2329
2284
  // ---- drafts: agent-proposed posts awaiting owner approval (.intentic/drafts/<id>.json) ----
2330
2285
  // One JSON file per draft. The AGENT creates drafts with its normal file tools — it can't call daemon routes,
@@ -2507,7 +2462,7 @@ export const BrowserPageSchema = z.object({
2507
2462
  // The page's own title. Absent mid-navigation, which is exactly when a tab still needs to render.
2508
2463
  title: z.string().optional(),
2509
2464
  url: z.string(),
2510
- // The page the agent last drove. Exactly one page per running session has it.
2465
+ // The page the agent last drove — on a finished session, the one it ended on. Exactly one page has it.
2511
2466
  active: z.boolean(),
2512
2467
  });
2513
2468
  export const BrowserSessionSchema = z.object({
@@ -2521,6 +2476,9 @@ export const BrowserSessionSchema = z.object({
2521
2476
  // still lists for a while, with the pages it had — the record of where the agent went.
2522
2477
  running: z.boolean(),
2523
2478
  activityAt: z.number(),
2479
+ // When that Chromium went away, for the "closed 20m ago" line a finished session leads with. Absent while
2480
+ // running, which is the same fact as `running` — but the view needs the timestamp, not just the flag.
2481
+ finishedAt: z.number().optional(),
2524
2482
  pages: z.array(BrowserPageSchema),
2525
2483
  });
2526
2484
  export type BrowserPage = z.infer<typeof BrowserPageSchema>;
@@ -2529,6 +2487,75 @@ export const BrowsersListSchema = z.object({ sessions: z.array(BrowserSessionSch
2529
2487
  export type BrowsersList = z.infer<typeof BrowsersListSchema>;
2530
2488
  export const BrowserNameParamSchema = z.object({ name: z.string() });
2531
2489
 
2490
+ /* ---- subagents: the agents an agent starts ----
2491
+ *
2492
+ * The third thing a turn spawns that the operator can be shown, after its shell and its browser — and the only
2493
+ * one that is itself an agent. Two kinds land in this one list, because from outside they are the same fact
2494
+ * (another agent, working, that you did not start):
2495
+ * • `subagent` — the SDK's Agent/Task tool. The daemon learns of it from the SubagentStart/SubagentStop hooks
2496
+ * and the task_* stream messages, joined on `toolUseId`.
2497
+ * • `codex` / `grok` — a CLI the agent drove from its own Bash (agent/delegation.ts). Detected in the Bash
2498
+ * PreToolUse hook, bound to its thread/session id from the command's output.
2499
+ *
2500
+ * `id` IS THE SPAWNING TOOL CALL'S id — the Agent card's, or the Bash card's for a delegation. It is the one key
2501
+ * every source already carries (the SDK's subagent meta, its task_* messages, and the `parentToolUseId` the
2502
+ * client nests inner frames under), so nothing has to be correlated: a card links to its subagent with the id it
2503
+ * already has, and the subagent points back at the card the same way. The ids the transcripts are actually READ
2504
+ * with — the SDK's agent id, a Codex thread, an OpenCode session — stay daemon-side, because no surface asks a
2505
+ * question they answer.
2506
+ *
2507
+ * WHAT A KIND CHANGES, and it is only ever the live view: a subagent has no process of its own to look at, so
2508
+ * watching it means reading its transcript. A delegation runs in a tmux window, so it has both — `terminal`
2509
+ * names it, and the card keeps its existing "Watch in terminal" beside the transcript door. */
2510
+ export const SubagentKindSchema = z.enum(["subagent", "codex", "grok"]);
2511
+ export type SubagentKind = z.infer<typeof SubagentKindSchema>;
2512
+
2513
+ // running/pending are live; the rest are terminal. Deliberately the SDK's own task vocabulary
2514
+ // (SDKTaskUpdatedMessage.patch.status) rather than AgentStatus: this is not a fleet card's lifecycle (no
2515
+ // draft/landed/conflict), and mapping the two would invent states neither side reports.
2516
+ export const SubagentStatusSchema = z.enum(["pending", "running", "completed", "failed", "killed", "paused"]);
2517
+ export type SubagentStatus = z.infer<typeof SubagentStatusSchema>;
2518
+
2519
+ export const SubagentSessionSchema = z.object({
2520
+ id: z.string(),
2521
+ kind: SubagentKindSchema,
2522
+ // The conversation whose turn spawned this — what the area groups its rows by, and the way back to the chat
2523
+ // the card lives in.
2524
+ conversationId: z.string(),
2525
+ // What it is and what it was asked to do: the subagent type (`Explore`, `general-purpose`) or the delegated
2526
+ // provider's model, and the caller's one-line description. The area's row and the card's title read as
2527
+ // `Explore · Locate claimIndexer definition`.
2528
+ agentType: z.string().optional(),
2529
+ description: z.string().optional(),
2530
+ model: z.string().optional(),
2531
+ // How deep in the spawn tree (1 = spawned by the turn itself). From the SDK's meta.json; a subagent may
2532
+ // itself delegate, and a flat list that cannot say so reads as though the turn started all of them.
2533
+ spawnDepth: z.number().optional(),
2534
+ // Backgrounded: the parent went on working instead of waiting for it. This is the whole reason the list
2535
+ // exists — a backgrounded child used to be invisible until its result landed, sometimes minutes later.
2536
+ background: z.boolean().optional(),
2537
+ status: SubagentStatusSchema,
2538
+ startedAt: z.number(),
2539
+ endedAt: z.number().optional(),
2540
+ activityAt: z.number(),
2541
+ // What it has spent and done so far (task_progress). Tokens are the child's own, so a parent's cost line and
2542
+ // the sum of its children's are two different true numbers.
2543
+ tokens: z.number().optional(),
2544
+ toolUses: z.number().optional(),
2545
+ lastTool: z.string().optional(),
2546
+ // Its report — the last assistant message (SubagentStop) or the task summary. The answer to "what did it
2547
+ // conclude?" without opening the transcript, which is the question a finished child is read for.
2548
+ summary: z.string().optional(),
2549
+ error: z.string().optional(),
2550
+ // A delegation's live view: the tmux session its command runs in. Absent for an SDK subagent, which has no
2551
+ // process of its own to attach to.
2552
+ terminal: z.string().optional(),
2553
+ });
2554
+ export type SubagentSession = z.infer<typeof SubagentSessionSchema>;
2555
+ export const SubagentsListSchema = z.object({ sessions: z.array(SubagentSessionSchema) });
2556
+ export type SubagentsList = z.infer<typeof SubagentsListSchema>;
2557
+ export const SubagentIdParamSchema = z.object({ id: z.string() });
2558
+
2532
2559
  // ---- environment: the overlay Dockerfile extending the sandbox image ----
2533
2560
  // The approved file is DAEMON-COMPOSED: pinned FROM + capability fragments + the owner-approved custom section.
2534
2561
  // The agent writes the proposal file (.intentic/environment.Dockerfile — custom-section content only, no FROM)
package/src/title.ts CHANGED
@@ -15,8 +15,12 @@
15
15
  * names a turn that arrived without one — an automation, a Discord message, a webchat visitor (agents-
16
16
  * registry.ts). One rule, because two would let the same prompt open under two different names depending on
17
17
  * where it entered. Nothing here calls a model: the title has to exist before the first frame comes back.
18
- * (A model DOES get its say later — the daemon's title-summary pass renames a still-derived title once the
19
- * first turn has an answer to read but that is a promotion over this title, never a replacement for it.) */
18
+ *
19
+ * Which is exactly the ceiling on it. Cutting is not naming what comes out is the user's own sentence,
20
+ * shortened, and a column of those is scannable only where the users' sentences happened to differ early. The
21
+ * name a conversation ends up WEARING is written a second or two later by a model that reads the same prompt
22
+ * and answers in the fleet board's own shape (the daemon's title-namer.ts). This is the title that holds the
23
+ * tab until that arrives, and the one it keeps if nothing is connected to write a better one. */
20
24
 
21
25
  // The registry's title budget (agents-registry MAX_TITLE_LENGTH, the rename input's maxlength) — the widest
22
26
  // any surface stores. Every surface truncates in CSS to its own width, so the clamp here is a storage cap,
@@ -0,0 +1,129 @@
1
+ import type { FileContribution } from "@intentic/extension-api";
2
+ import { describe, expect, it } from "vitest";
3
+ import { staleQueryKeys, WORKSPACE_STATE_FILES } from "./workspace-state.js";
4
+
5
+ // What the automations extension declares in its manifest, and what the memory extension WOULD declare if the
6
+ // watcher reported its files. Literals rather than the real manifests: an extension package importing this one is
7
+ // the dependency direction, so reaching back for them here would invert it. The real manifests are checked
8
+ // against this rule where they are loaded — web's fileBindings.test.ts and the daemon's file-bindings.test.ts.
9
+ const AUTOMATIONS: readonly FileContribution[] = [
10
+ { path: `.intentic/automations.json`, invalidates: [`automations`] },
11
+ { path: `.intentic/approvals/`, invalidates: [`automation-approvals`] },
12
+ ];
13
+
14
+ describe(`staleQueryKeys`, () => {
15
+ it(`maps a manifest write to the queries it makes stale`, () => {
16
+ expect(staleQueryKeys([`.intentic/capabilities.json`], [])).toEqual([`capabilities`, `environment`, `panels`]);
17
+ });
18
+
19
+ it(`matches a name family and a one-file-per-entry directory through one prefix each`, () => {
20
+ // environment.Dockerfile, environment.custom.Dockerfile, environment.approved.Dockerfile — one entry.
21
+ expect(staleQueryKeys([`.intentic/environment.custom.Dockerfile`], [])).toEqual([`environment`]);
22
+ expect(staleQueryKeys([`.intentic/drafts/post-1.json`], [])).toEqual([`drafts`]);
23
+ });
24
+
25
+ it(`refreshes the Drafts view when the AGENT writes a draft`, () => {
26
+ // The regression this table was reorganized around: the drafts skill writes these files directly, so
27
+ // there is no browser mutation to hang an invalidate on — the watcher push is the only signal, and it
28
+ // used to be dropped on the floor.
29
+ expect(staleQueryKeys([`.intentic/drafts/post-1.json`], [])).toEqual([`drafts`]);
30
+ });
31
+
32
+ it(`ignores unrelated churn under .intentic/`, () => {
33
+ // The amplification that turned an iq index rebuild into an endless request storm: a prefix test on
34
+ // `.intentic/` alone would invalidate every one of these queries for each index write.
35
+ expect(staleQueryKeys([`.intentic/iq/index.db`, `.intentic/claude/projects/p/session.jsonl`], [])).toEqual([]);
36
+ });
37
+
38
+ it(`ignores a store's own temp file while it is mid-swap`, () => {
39
+ // jsonFile writes `.<name>.<pid>.tmp` beside the target precisely so the atomic rename can't be read as
40
+ // a write to the target itself. A trailing-tag temp would prefix-match and bill an extra refetch.
41
+ expect(staleQueryKeys([`.intentic/.settings.json.42.tmp`], [])).toEqual([]);
42
+ expect(staleQueryKeys([`.intentic/settings.json`], [])).toEqual([`settings`]);
43
+ });
44
+
45
+ it(`ignores ordinary workspace edits`, () => {
46
+ expect(staleQueryKeys([`src/main.ts`, `README.md`], [])).toEqual([]);
47
+ });
48
+
49
+ it(`dedupes keys across a batch that touches several manifests`, () => {
50
+ // A capability add recomposes the overlay, so both entries claim `environment` — one refetch, not two.
51
+ expect(staleQueryKeys([`.intentic/capabilities.json`, `.intentic/environment.Dockerfile`], [])).toEqual([
52
+ `capabilities`,
53
+ `environment`,
54
+ `panels`,
55
+ ]);
56
+ });
57
+
58
+ it(`invalidates an extension's queries from its own declaration`, () => {
59
+ expect(staleQueryKeys([`.intentic/automations.json`], AUTOMATIONS)).toEqual([`automations`]);
60
+ expect(staleQueryKeys([`.intentic/approvals/a1.json`], AUTOMATIONS)).toEqual([`automation-approvals`]);
61
+ });
62
+
63
+ it(`makes nothing stale for an extension that is not running`, () => {
64
+ // The reason the live set is passed in rather than read off the installed list: `automations` is the
65
+ // extension's query key, so with the extension gone there is no cache entry for it to be about. The core
66
+ // table used to carry these two keys itself, and would have kept invalidating them either way.
67
+ expect(staleQueryKeys([`.intentic/automations.json`, `.intentic/approvals/a1.json`], [])).toEqual([]);
68
+ });
69
+
70
+ it(`lets an extension claim a path the core table deliberately ignores`, () => {
71
+ // The two lists are unioned flat, not layered: a narrow extension entry under a broad core entry that
72
+ // invalidates nothing must still fire. Without this, every path beneath one of the daemon's
73
+ // machine-state prefixes would be unreachable to extensions.
74
+ const nested: readonly FileContribution[] = [{ path: `.intentic/claude/projects/p/memory/`, invalidates: [`memory`] }];
75
+ expect(staleQueryKeys([`.intentic/claude/projects/p/memory/note.md`], nested)).toEqual([`memory`]);
76
+ // …and a sibling under the same core prefix stays ignored.
77
+ expect(staleQueryKeys([`.intentic/claude/projects/p/session.jsonl`], nested)).toEqual([]);
78
+ });
79
+
80
+ it(`dedupes a key two extensions both claim`, () => {
81
+ const twice: readonly FileContribution[] = [
82
+ { path: `.intentic/automations.json`, invalidates: [`automations`] },
83
+ { path: `.intentic/automations.json`, invalidates: [`automations`] },
84
+ ];
85
+ expect(staleQueryKeys([`.intentic/automations.json`], twice)).toEqual([`automations`]);
86
+ });
87
+ });
88
+
89
+ describe(`WORKSPACE_STATE_FILES`, () => {
90
+ it(`declares every entry under .intentic/, root-relative and forward-slash`, () => {
91
+ for (const file of WORKSPACE_STATE_FILES) {
92
+ expect(file.path.startsWith(`.intentic/`), file.path).toBe(true);
93
+ expect(file.path.includes(`\\`), file.path).toBe(false);
94
+ }
95
+ });
96
+
97
+ it(`states a reason for every entry that invalidates nothing`, () => {
98
+ // An empty `invalidates` is a real answer (daemon machine state, a deliberately-polled surface, a path
99
+ // whose query keys belong to an extension), but a SILENT one is indistinguishable from the omission this
100
+ // table exists to prevent — which is exactly how drafts went missing. Requiring the reason is what makes
101
+ // the difference visible at review time.
102
+ for (const file of WORKSPACE_STATE_FILES) {
103
+ if (file.invalidates.length === 0) {
104
+ expect(file.why, `${file.path} invalidates nothing and must say why`).toBeTruthy();
105
+ } else {
106
+ expect(file.why, `${file.path} invalidates queries, so \`why\` is dead weight`).toBeUndefined();
107
+ }
108
+ }
109
+ });
110
+
111
+ it(`keeps directory entries slash-terminated so they cannot swallow a sibling`, () => {
112
+ // `.intentic/drafts` without the slash would also prefix-match a future `.intentic/drafts-archive.json`.
113
+ for (const file of WORKSPACE_STATE_FILES.filter((entry) => entry.invalidates.length > 0)) {
114
+ const isFamilyPrefix = file.path.endsWith(`.`);
115
+ const isFile = file.path.endsWith(`.json`) || file.path.endsWith(`.Dockerfile`);
116
+ expect(isFile || isFamilyPrefix || file.path.endsWith(`/`), file.path).toBe(true);
117
+ }
118
+ });
119
+
120
+ it(`has no entry that prefix-matches another, so one write can't be billed twice`, () => {
121
+ for (const file of WORKSPACE_STATE_FILES) {
122
+ const overlapping = WORKSPACE_STATE_FILES.filter((other) => other !== file && other.path.startsWith(file.path));
123
+ expect(
124
+ overlapping.map((other) => other.path),
125
+ `${file.path} is a prefix of another entry`,
126
+ ).toEqual([]);
127
+ }
128
+ });
129
+ });