@intentic/sandbox-contract 1.288.0 → 1.291.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 (97) hide show
  1. package/README.md +4 -1
  2. package/dist/contracts/agent.contract.d.ts +54 -12
  3. package/dist/contracts/agent.contract.d.ts.map +1 -1
  4. package/dist/contracts/agents.contract.d.ts +14 -0
  5. package/dist/contracts/agents.contract.d.ts.map +1 -1
  6. package/dist/contracts/agents.contract.js +4 -4
  7. package/dist/contracts/agents.contract.js.map +1 -1
  8. package/dist/contracts/diff.contract.d.ts +49 -0
  9. package/dist/contracts/diff.contract.d.ts.map +1 -0
  10. package/dist/contracts/diff.contract.js +14 -0
  11. package/dist/contracts/diff.contract.js.map +1 -0
  12. package/dist/contracts/extensions.contract.d.ts +1 -0
  13. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  14. package/dist/contracts/runner.contract.d.ts +95 -95
  15. package/dist/contracts/sessions.contract.d.ts +13 -0
  16. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  17. package/dist/contracts/system.contract.d.ts +58 -30
  18. package/dist/contracts/system.contract.d.ts.map +1 -1
  19. package/dist/contracts/usage.contract.d.ts +15 -0
  20. package/dist/contracts/usage.contract.d.ts.map +1 -1
  21. package/dist/contracts/usage.contract.js +12 -2
  22. package/dist/contracts/usage.contract.js.map +1 -1
  23. package/dist/events/agent-events.d.ts +39 -0
  24. package/dist/events/agent-events.d.ts.map +1 -1
  25. package/dist/events/transcript.d.ts +95 -0
  26. package/dist/events/transcript.d.ts.map +1 -1
  27. package/dist/events/transcript.js +16 -2
  28. package/dist/events/transcript.js.map +1 -1
  29. package/dist/events/verify-nudge.d.ts +4 -0
  30. package/dist/events/verify-nudge.d.ts.map +1 -0
  31. package/dist/events/verify-nudge.js +4 -0
  32. package/dist/events/verify-nudge.js.map +1 -0
  33. package/dist/events/watch-wake.d.ts +14 -0
  34. package/dist/events/watch-wake.d.ts.map +1 -0
  35. package/dist/events/watch-wake.js +42 -0
  36. package/dist/events/watch-wake.js.map +1 -0
  37. package/dist/index.d.ts +168 -13
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +7 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/models/agent-catalog.d.ts +1 -0
  42. package/dist/models/agent-catalog.d.ts.map +1 -1
  43. package/dist/models/agent-catalog.js +1 -0
  44. package/dist/models/agent-catalog.js.map +1 -1
  45. package/dist/models/agent-runtimes.d.ts.map +1 -1
  46. package/dist/models/agent-runtimes.js +2 -2
  47. package/dist/models/agent-runtimes.js.map +1 -1
  48. package/dist/models/plan-pools.d.ts +13 -0
  49. package/dist/models/plan-pools.d.ts.map +1 -1
  50. package/dist/models/plan-pools.js +43 -0
  51. package/dist/models/plan-pools.js.map +1 -1
  52. package/dist/schemas/agents.d.ts +4 -0
  53. package/dist/schemas/agents.d.ts.map +1 -1
  54. package/dist/schemas/agents.js +3 -0
  55. package/dist/schemas/agents.js.map +1 -1
  56. package/dist/schemas/devices.d.ts +21 -0
  57. package/dist/schemas/devices.d.ts.map +1 -1
  58. package/dist/schemas/devices.js +7 -1
  59. package/dist/schemas/devices.js.map +1 -1
  60. package/dist/schemas/diff.d.ts +62 -0
  61. package/dist/schemas/diff.d.ts.map +1 -0
  62. package/dist/schemas/diff.js +59 -0
  63. package/dist/schemas/diff.js.map +1 -0
  64. package/dist/schemas/extension-updates.d.ts +2 -0
  65. package/dist/schemas/extension-updates.d.ts.map +1 -1
  66. package/dist/schemas/terminal.d.ts +22 -0
  67. package/dist/schemas/terminal.d.ts.map +1 -1
  68. package/dist/schemas/terminal.js +9 -0
  69. package/dist/schemas/terminal.js.map +1 -1
  70. package/dist/state/history-state.d.ts.map +1 -1
  71. package/dist/state/history-state.js +1 -0
  72. package/dist/state/history-state.js.map +1 -1
  73. package/dist/text/transcript-fold.d.ts +1 -0
  74. package/dist/text/transcript-fold.d.ts.map +1 -1
  75. package/dist/text/transcript-fold.js +17 -10
  76. package/dist/text/transcript-fold.js.map +1 -1
  77. package/package.json +5 -5
  78. package/src/contracts/agents.contract.ts +4 -3
  79. package/src/contracts/diff.contract.ts +17 -0
  80. package/src/contracts/usage.contract.ts +19 -2
  81. package/src/events/transcript.ts +25 -2
  82. package/src/events/verify-nudge.ts +14 -0
  83. package/src/events/watch-wake.test.ts +67 -0
  84. package/src/events/watch-wake.ts +78 -0
  85. package/src/index.ts +7 -1
  86. package/src/models/agent-catalog.test.ts +6 -2
  87. package/src/models/agent-catalog.ts +5 -0
  88. package/src/models/agent-runtimes.ts +7 -4
  89. package/src/models/plan-pools.test.ts +40 -1
  90. package/src/models/plan-pools.ts +69 -0
  91. package/src/schemas/agents.ts +6 -0
  92. package/src/schemas/devices.ts +20 -1
  93. package/src/schemas/diff.ts +72 -0
  94. package/src/schemas/terminal.ts +11 -0
  95. package/src/state/history-state.ts +3 -0
  96. package/src/text/transcript-fold.test.ts +21 -0
  97. package/src/text/transcript-fold.ts +23 -11
@@ -8,6 +8,23 @@ export const RefreshPlanLimitsSchema = z.object({
8
8
  force: z.boolean().default(false).describe("Measure again even if a reading was taken a moment ago."),
9
9
  });
10
10
 
11
+ // What a re-measure could not read. A provider rate-limits these reads per account, and while it is holding one off
12
+ // the number on screen cannot move: without this the caller can only report a press that changed nothing.
13
+ export const PlanLimitsRefreshedSchema = z.object({
14
+ ok: z.literal(true),
15
+ held: z
16
+ .array(
17
+ z.object({
18
+ provider: z.string().describe("Which provider is holding the read off."),
19
+ account: z.string().describe("The account as its provider's list names it: an account id, or a routed auth file's name."),
20
+ resumesAt: z.number().describe("Unix seconds: when this account may be read again, the provider's own retry-after."),
21
+ }),
22
+ )
23
+ .describe("Accounts whose plan limits could not be read now because the provider is rate-limiting them."),
24
+ });
25
+ export type PlanLimitsRefreshed = z.infer<typeof PlanLimitsRefreshedSchema>;
26
+ export type PlanLimitsHeld = PlanLimitsRefreshed["held"][number];
27
+
11
28
  // Durable spend ledger, read-only over the wire; rows are appended daemon-side at turn end.
12
29
  // `rollup` groups by day, provider, account and model, so every cost panel re-projects from this one answer.
13
30
  export const usageContract = {
@@ -28,10 +45,10 @@ export const usageContract = {
28
45
  path: "/usage/plan-limits/refresh",
29
46
  summary: "Measure every account's plan limits again",
30
47
  description:
31
- "Reads how full each connected account's plan limits are, for every provider, and records it. Forced, it measures even accounts read a moment ago, which is the right thing when a plan was just changed and the question is whether the number on screen is still true.",
48
+ "Reads how full each connected account's plan limits are, for every provider, and records it. Forced, it measures even accounts read a moment ago, which is the right thing when a plan was just changed and the question is whether the number on screen is still true. Answers with the accounts it could not read because the provider is rate-limiting them, and when each may be asked again: those keep the reading they already had, so a number that does not move is explained rather than silent.",
32
49
  })
33
50
  .input(RefreshPlanLimitsSchema)
34
- .output(z.object({ ok: z.literal(true) })),
51
+ .output(PlanLimitsRefreshedSchema),
35
52
  // Asked, not polled: the provider only evaluates this when told the account is at the wall, and the answer isn't
36
53
  // cached.
37
54
  // Answers `available: false` rather than failing when the account has no such mechanism.
@@ -120,6 +120,23 @@ export interface TranscriptTool {
120
120
  subagent?: TranscriptSubagent | undefined;
121
121
  }
122
122
 
123
+ // Three endings that wake a conversation off a condition watch: the first two are promised when it is armed, the third
124
+ // is the daemon's own (a deadline that passed while it was down). Composing and parsing the wake is watch-wake.ts.
125
+ export const WatchOutcomeSchema = z.enum(["met", "timeout", "restart-expired"]);
126
+ export type WatchOutcome = z.infer<typeof WatchOutcomeSchema>;
127
+
128
+ // A condition watch waking the conversation, as the row carries it. The wake arrives as an ordinary turn prompt that
129
+ // nobody typed, so the row keeps that prompt verbatim rather than a summary of it.
130
+ export const TranscriptWatchWakeSchema = z.object({
131
+ outcome: WatchOutcomeSchema.describe("How the watch ended: the condition held, the deadline passed, or a restart cut it short."),
132
+ note: z.string().describe("The agent's own line on what it was waiting for."),
133
+ elapsed: z
134
+ .string()
135
+ .describe("How long the watch stood, already worded ('43m'): carried rather than recomputed, since the arming instant is not on the row."),
136
+ sent: z.string().describe("The whole prompt the model was woken with, disclosed under the row."),
137
+ });
138
+ export type TranscriptWatchWake = z.infer<typeof TranscriptWatchWakeSchema>;
139
+
123
140
  // One note the daemon put before a user's message: the model reads `text`, the chat draws `title` on a row that opens
124
141
  // to it. Shared by the live frame and the restored transcript, so it reads the same either way.
125
142
  export const TurnNoteSchema = z.object({
@@ -191,14 +208,19 @@ export const TranscriptRowSchema = z.object({
191
208
  ),
192
209
  // The one-press follow-up this notice offers, by name; the chat decides what it does, and whether it stands.
193
210
  noticeAction: z
194
- .enum(["landHold", "outageOptOut", "depsInstall", "tierHold"])
211
+ .enum(["landHold", "outageOptOut", "depsInstall", "tierHold", "watchStop"])
195
212
  .optional()
196
213
  .describe("A one-press follow-up this notice offers, by name. The chat decides what it does and whether it still applies."),
197
214
  // An unfinished wait this notice describes, by name; whether it's still running is live state, not stored here.
198
215
  noticeWait: z
199
- .enum(["credentialRenewal", "personaRoute"])
216
+ .enum(["credentialRenewal", "personaRoute", "watch"])
200
217
  .optional()
201
218
  .describe("The wait this notice describes, by name, so a reader can say whether it is still on."),
219
+ // Which one, for a wait whose kind can have several in flight at once; without it two armed watches settle together.
220
+ noticeWaitId: z
221
+ .string()
222
+ .optional()
223
+ .describe("Which instance of the wait this notice names, for a kind that can have several running at once."),
202
224
  // At most one card per row; a card closes its bubble. One field per kind, so a reader reaches it by name.
203
225
  plan: TranscriptPlanSchema.optional().describe("The plan this row asked approval for, and the answer."),
204
226
  question: TranscriptQuestionSchema.optional().describe("The questions this row asked, and the picks that answered them."),
@@ -207,6 +229,7 @@ export const TranscriptRowSchema = z.object({
207
229
  terminalHelp: TranscriptTerminalHelpSchema.optional().describe("The terminal hand-over this row asked for, and how it ended."),
208
230
  capabilityOffer: TranscriptCapabilityOfferSchema.optional().describe("The capability setup this row asked for, the decision, and the outcome."),
209
231
  paymentOffer: TranscriptPaymentOfferSchema.optional().describe("The payment this row asked for, the decision, and the receipt."),
232
+ watchWake: TranscriptWatchWakeSchema.optional().describe("The condition watch that woke this conversation, and the prompt it was woken with."),
210
233
  credentialOffer: TranscriptCredentialOfferSchema.optional().describe(
211
234
  "The gated credential this row asked to use, who may release it, and who did.",
212
235
  ),
@@ -0,0 +1,14 @@
1
+ // The follow-up the daemon sends when a turn ended with work it could not confirm: the runtimes with no Stop hook get
2
+ // their turn.ending asks this way (verify-nudge.ts), as an ordinary prompt. Its opening lives on the wire because the
3
+ // chat has to recognise it coming back: a prompt nobody typed must not reach a reader as their own words.
4
+
5
+ // Anchored on by the reader, so it must stay unique and stable across releases — a reworded opening un-recognises every
6
+ // nudge already in a record, and they read as the user's own typing again.
7
+ export const VERIFY_NUDGE_OPENING =
8
+ "The turn you just finished left work this sandbox could not confirm, so it is asking before the work is called done. Each item below is either a check that did not pass, which you repair, or something the turn changed without showing it works, which you prove. Deal with every one of them, then say plainly what you ran and what it covered.";
9
+
10
+ // The prompt a nudge actually sends: its opening, then the findings and asks, each already worded for the model.
11
+ export const verifyNudgePrompt = (parts: readonly string[]): string => [VERIFY_NUDGE_OPENING, ...parts].join("\n\n");
12
+
13
+ // Whether a stored prompt is one, so any reader can ask without checking first.
14
+ export const isVerifyNudge = (prompt: string): boolean => prompt.startsWith(VERIFY_NUDGE_OPENING);
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { WatchOutcome } from "./transcript.js";
3
+ import { watchWakeOf, watchWakePrompt, watchWakeRow, type WatchWakeFields } from "./watch-wake.js";
4
+
5
+ const fields = (over: Partial<WatchWakeFields> = {}): WatchWakeFields => ({
6
+ outcome: "met",
7
+ id: "watch-2",
8
+ note: "CI run 316 on intentic/intentic",
9
+ elapsed: "43m",
10
+ command: "gh run view 316 --json status",
11
+ exitCode: 0,
12
+ output: "completed success",
13
+ ...over,
14
+ });
15
+
16
+ const OUTCOMES: readonly WatchOutcome[] = ["met", "timeout", "restart-expired"];
17
+
18
+ describe("watch wake", () => {
19
+ // The composer and the parser are the same piece of knowledge; this is what holds them together when either is
20
+ // reworded.
21
+ it.each(OUTCOMES)("round-trips a %s wake back to its fields", (outcome) => {
22
+ const wake = watchWakeOf(watchWakePrompt(fields({ outcome })));
23
+ expect(wake).toEqual({ outcome, note: "CI run 316 on intentic/intentic", elapsed: "43m", sent: expect.any(String) });
24
+ });
25
+
26
+ it("keeps the prompt verbatim on the row, since nobody typed it", () => {
27
+ const prompt = watchWakePrompt(fields());
28
+ expect(watchWakeOf(prompt)?.sent).toBe(prompt);
29
+ });
30
+
31
+ it("carries the watch id in the body, not the opening", () => {
32
+ const prompt = watchWakePrompt(fields());
33
+ expect(prompt.split("\n")[0]).not.toContain("watch-2");
34
+ expect(prompt).toContain("Watch id: watch-2");
35
+ });
36
+
37
+ it("says an absent exit code in words, since the check never reported one", () => {
38
+ expect(watchWakePrompt(fields({ exitCode: undefined }))).toContain("Last exit code: none (check was killed or failed to start)");
39
+ });
40
+
41
+ it("omits the output block when the check said nothing", () => {
42
+ expect(watchWakePrompt(fields({ output: "" }))).not.toContain("Last output (tail):");
43
+ });
44
+
45
+ it("draws a notice row, never a user row: a watch firing is neither side speaking", () => {
46
+ const row = watchWakeRow(watchWakePrompt(fields()));
47
+ expect(row?.role).toBe("notice");
48
+ expect(row?.text).toBe("CI run 316 on intentic/intentic — the watch fired after 43m.");
49
+ });
50
+
51
+ it("words each ending differently, since each calls for a different next step", () => {
52
+ const textOf = (outcome: WatchOutcome): string => watchWakeRow(watchWakePrompt(fields({ outcome })))?.text ?? "";
53
+ expect(textOf("timeout")).toBe("CI run 316 on intentic/intentic — the watch gave up after 43m.");
54
+ expect(textOf("restart-expired")).toBe("CI run 316 on intentic/intentic — the watch stopped when the sandbox restarted, after 43m.");
55
+ });
56
+
57
+ it("ignores a prompt that is not a wake, so any reader can ask without checking first", () => {
58
+ expect(watchWakeOf("fix the bug")).toBeUndefined();
59
+ expect(watchWakeRow("Watch fired: something I typed myself")).toBeUndefined();
60
+ });
61
+
62
+ it("refuses a wake whose labelled lines are gone, rather than putting a blank note on the row", () => {
63
+ const prompt = watchWakePrompt(fields());
64
+ expect(watchWakeOf(prompt.replace("Watching: CI run 316 on intentic/intentic\n", ""))).toBeUndefined();
65
+ expect(watchWakeOf(prompt.replace("Elapsed: 43m\n", ""))).toBeUndefined();
66
+ });
67
+ });
@@ -0,0 +1,78 @@
1
+ import type { TranscriptRow, TranscriptWatchWake, WatchOutcome } from "./transcript.js";
2
+
3
+ // What a condition watch says when it wakes a conversation, and how that reads to a person. The wake is delivered as an
4
+ // ordinary turn prompt, so composing it and recognising it must stay one piece of knowledge: three separate readers
5
+ // (the daemon's record, a steered live turn, a provider's session store) turn that prompt back into a row.
6
+
7
+ // Per-outcome opening sentence, which is also what the parser anchors on: keep each unique and stable across releases,
8
+ // or a reworded opening un-recognises wakes already in a record and they read as the user's own words again.
9
+ const OPENINGS: Record<WatchOutcome, string> = {
10
+ met: "Watch fired: the condition you were watching is now met.",
11
+ timeout: "Watch timed out: the deadline passed and the check never exited 0. Decide whether to re-arm it, investigate the check, or report back.",
12
+ "restart-expired":
13
+ "Watch stopped: its deadline passed while the daemon was restarting, so it went unchecked for part of that window. It has just been re-checked once and the condition still does not hold. Decide whether to re-arm it, investigate the check, or report back.",
14
+ };
15
+
16
+ // Labels the prompt writes and the parser reads back. `Watching` and `Elapsed` are load-bearing on both sides; the rest
17
+ // are for the model and the reader only.
18
+ const WATCHING = "Watching: ";
19
+ const ELAPSED = "Elapsed: ";
20
+
21
+ export interface WatchWakeFields {
22
+ readonly outcome: WatchOutcome;
23
+ readonly id: string;
24
+ readonly note: string;
25
+ readonly elapsed: string;
26
+ readonly command: string;
27
+ // Undefined when the check never exited on its own (killed at its timeout, or failed to spawn).
28
+ readonly exitCode: number | undefined;
29
+ readonly output: string;
30
+ }
31
+
32
+ // The prompt a wake actually sends. The watch id sits in the body rather than the opening: it is the handle for
33
+ // `watch stop`, so it has to be here, but it is the least useful thing on the page to read first.
34
+ export const watchWakePrompt = (fields: WatchWakeFields): string =>
35
+ [
36
+ OPENINGS[fields.outcome],
37
+ `${WATCHING}${fields.note}`,
38
+ `${ELAPSED}${fields.elapsed}`,
39
+ `Watch id: ${fields.id}`,
40
+ `Check command: ${fields.command}`,
41
+ `Last exit code: ${fields.exitCode === undefined ? "none (check was killed or failed to start)" : fields.exitCode}`,
42
+ ...(fields.output === "" ? [] : ["Last output (tail):", "```", fields.output, "```"]),
43
+ "Continue the task this watch was armed for.",
44
+ ].join("\n");
45
+
46
+ // The one line a person reads. Present tense for the ending itself, since the row appears the moment it happens.
47
+ const headline = (outcome: WatchOutcome, note: string, elapsed: string): string => {
48
+ if (outcome === "met") {
49
+ return `${note} — the watch fired after ${elapsed}.`;
50
+ }
51
+ if (outcome === "timeout") {
52
+ return `${note} — the watch gave up after ${elapsed}.`;
53
+ }
54
+ return `${note} — the watch stopped when the sandbox restarted, after ${elapsed}.`;
55
+ };
56
+
57
+ const valueOn = (lines: readonly string[], label: string): string | undefined => lines.find((line) => line.startsWith(label))?.slice(label.length);
58
+
59
+ // Which wake a stored prompt is, if any; undefined for every prompt that isn't one, so any reader can ask without
60
+ // checking first. A prompt that opens as a wake but lost its labelled lines is not one: half-parsing it would put a
61
+ // blank note on the row and lose the words with it.
62
+ export const watchWakeOf = (prompt: string): TranscriptWatchWake | undefined => {
63
+ const outcome = (Object.keys(OPENINGS) as WatchOutcome[]).find((key) => prompt.startsWith(OPENINGS[key]));
64
+ if (outcome === undefined) {
65
+ return undefined;
66
+ }
67
+ const lines = prompt.split("\n");
68
+ const note = valueOn(lines, WATCHING);
69
+ const elapsed = valueOn(lines, ELAPSED);
70
+ return note === undefined || elapsed === undefined ? undefined : { outcome, note, elapsed, sent: prompt };
71
+ };
72
+
73
+ // The row a wake becomes, wherever a prompt is turned into rows: a notice, because a watch firing is something that
74
+ // happened to the turn rather than anything either side said. Undefined when the prompt is not a wake at all.
75
+ export const watchWakeRow = (prompt: string): TranscriptRow | undefined => {
76
+ const wake = watchWakeOf(prompt);
77
+ return wake === undefined ? undefined : { role: "notice", text: headline(wake.outcome, wake.note, wake.elapsed), watchWake: wake };
78
+ };
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { automationsContract } from "./contracts/automations.contract.js";
9
9
  import { capabilitiesContract } from "./contracts/capabilities.contract.js";
10
10
  import { choresContract } from "./contracts/chores.contract.js";
11
11
  import { ciContract } from "./contracts/ci.contract.js";
12
+ import { diffContract } from "./contracts/diff.contract.js";
12
13
  import { endpointsContract } from "./contracts/endpoints.contract.js";
13
14
  import { exitContract } from "./contracts/exit.contract.js";
14
15
  import { extensionsContract } from "./contracts/extensions.contract.js";
@@ -52,6 +53,7 @@ export { endpointsContract, type TrialHealth, TrialStatusSchema, type TrialStatu
52
53
  export { exitContract } from "./contracts/exit.contract.js";
53
54
  export { extensionsContract } from "./contracts/extensions.contract.js";
54
55
  export { personasContract } from "./contracts/personas.contract.js";
56
+ export { diffContract } from "./contracts/diff.contract.js";
55
57
  export { gitContract } from "./contracts/git.contract.js";
56
58
  export { historyContract } from "./contracts/history.contract.js";
57
59
  // Not part of `sandboxContract` below: spoken over a device's WebSocket, with the machine implementing it.
@@ -81,7 +83,7 @@ export { shareContract } from "./contracts/share.contract.js";
81
83
  export { skillsContract } from "./contracts/skills.contract.js";
82
84
  export { systemContract } from "./contracts/system.contract.js";
83
85
  export { translatorContract } from "./contracts/translator.contract.js";
84
- export { usageContract } from "./contracts/usage.contract.js";
86
+ export { type PlanLimitsHeld, type PlanLimitsRefreshed, PlanLimitsRefreshedSchema, usageContract } from "./contracts/usage.contract.js";
85
87
  export { vpnContract } from "./contracts/vpn.contract.js";
86
88
  export { workflowsContract } from "./contracts/workflows.contract.js";
87
89
  export { workspaceContract } from "./contracts/workspace.contract.js";
@@ -90,6 +92,8 @@ export * from "./events/cards.js";
90
92
  export * from "./events/resume.js";
91
93
  export * from "./events/system-events.js";
92
94
  export * from "./events/transcript.js";
95
+ export * from "./events/verify-nudge.js";
96
+ export * from "./events/watch-wake.js";
93
97
  export * from "./policy/card-status.js";
94
98
  export * from "./text/mentions.js";
95
99
  export * from "./protocol/sse.js";
@@ -151,6 +155,7 @@ export * from "./schemas/environment.js";
151
155
  export * from "./schemas/exit.js";
152
156
  export * from "./schemas/extension-updates.js";
153
157
  export * from "./schemas/providers/fast-mode.js";
158
+ export * from "./schemas/diff.js";
154
159
  export * from "./schemas/git/git.js";
155
160
  export * from "./schemas/git/git-history.js";
156
161
  export * from "./schemas/history.js";
@@ -209,6 +214,7 @@ export const sandboxContract = {
209
214
  capabilities: capabilitiesContract,
210
215
  chores: choresContract,
211
216
  ci: ciContract,
217
+ diff: diffContract,
212
218
  endpoints: endpointsContract,
213
219
  extensions: extensionsContract,
214
220
  personas: personasContract,
@@ -204,10 +204,14 @@ test("the instruction axis discloses its two weaker answers, differently", () =>
204
204
  expect(limitationsOf(acpCaps).length).toBeGreaterThan(limitationsOf(codexCaps).length);
205
205
  });
206
206
 
207
- test("only the Claude Code loop hosts the js execution backend", () => {
207
+ // The backend is one of the daemon's own functions, so it takes a runtime that can call one directly AND a gate the
208
+ // daemon controls: Claude Code has the SDK server plus its PreToolUse matcher, Cursor has customTools plus a consult
209
+ // inside the handler. Every other runtime is a child process with neither, where a script would run unread.
210
+ test("the js execution backend rides the two runtimes that can host the daemon's own tools", () => {
211
+ const hosts: ReadonlySet<string> = new Set(["claude-code", "cursor"]);
208
212
  for (const { provider, harness } of pairs) {
209
213
  const capabilities = capabilitiesOf(provider, harness);
210
- expect(capabilities.execution.includes("js")).toBe(capabilities.runtime === "claude-code");
214
+ expect(capabilities.execution.includes("js")).toBe(hosts.has(capabilities.runtime));
211
215
  }
212
216
  });
213
217
 
@@ -153,6 +153,11 @@ export const modelsFor = (provider: AgentProvider): CatalogOption[] => {
153
153
  return [];
154
154
  };
155
155
 
156
+ // Every reasoning tier a catalog row's `efforts` may name, weakest first. One vocabulary for every provider, so the
157
+ // picker can label and order a ladder it has never seen; a provider spelling a rung its own way translates on the way
158
+ // in, at the adapter that read it.
159
+ export const EFFORT_TIERS: readonly string[] = ["minimal", "low", "medium", "high", "xhigh", "max"];
160
+
156
161
  // Filters exactly one pair: Claude's `max` with thinking explicitly false, the one combination Anthropic's API refuses
157
162
  // (400). Absent thinking is not off; the model's own default answers, every other tier or provider passes through.
158
163
  export const effortAllowed = (effort: string, provider: AgentProvider, thinking: boolean | undefined): boolean =>
@@ -171,15 +171,17 @@ export const PI: AgentCapabilities = {
171
171
  // other runtimes lack are function arguments here. The harness axis doesn't apply: the SDK is the only door.
172
172
  export const CURSOR: AgentCapabilities = {
173
173
  runtime: "cursor",
174
- // The SDK's Run can be cancelled but not written to mid-flight; a second send errors rather than injecting.
175
- steering: false,
174
+ // Run.steer injects into the live run; only a `complete_delivered` ack transfers ownership of the message.
175
+ steering: true,
176
176
  // Cursor's own plan mode, not this repo's emulation; not "modes" since the hook can't gate the whole surface.
177
177
  permissions: "plan",
178
178
  // True since the daemon supplies its own ask tool; Cursor's own can fabricate an answer, so it's disallowed.
179
179
  questions: true,
180
180
  // stdio + http/sse MCP servers plus host callbacks; everything but a Claude Code plugin checkout.
181
181
  mcp: "tools",
182
- execution: ["shell"],
182
+ // The JS backend rides Cursor's custom-tool seam rather than an MCP server, and consults the rulebook in its
183
+ // handler, since the hook file only covers the shell.
184
+ execution: ["shell", "js"],
183
185
  // Cursor publishes effort as model parameters, not one scale; true here just means it's forwardable at all.
184
186
  effort: true,
185
187
  fastMode: false,
@@ -191,7 +193,8 @@ export const CURSOR: AgentCapabilities = {
191
193
  terminals: false,
192
194
  // The SDK throws typed errors instead of dissolving a refusal into prose, so the adapter files coded frames.
193
195
  recovery: true,
194
- // append, reached differently: `beforeSubmitPrompt`'s reply folds the prompt onto Cursor's base, unreplaceable.
196
+ // append, reached differently: `beforeSubmitPrompt`'s reply folds the daemon's text onto Cursor's base. The SDK can
197
+ // replace that base outright, but only for an entitled account, so the seam every turn can reach is this one.
195
198
  instructions: "append",
196
199
  skillDiscovery: "prompt",
197
200
  // The full hook tier, the only foreign runtime to reach it: a hold parks since the vendor waits on the hook.
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "vitest";
2
- import { bindingWindow, gatesModel, gatingWindows, scopedWindow } from "./plan-pools.js";
2
+ import { bindingWindow, gatesModel, gatingWindows, scopedWindow, windowLive, windowPeriod } from "./plan-pools.js";
3
3
  import type { AccountUsage, UsageWindow } from "../schemas/providers/plan-limits.js";
4
4
 
5
5
  // One rule for which pool blocks a given model, shared by the daemon and the browser: a plan can meter models
@@ -64,3 +64,42 @@ test("names the pool a plan meters this model by on its own, preferring the more
64
64
  const tied = usage(window({ kind: "model:Opus", gates: { models: ["Opus"] } }), window({ kind: "model:Claude", gates: { models: ["Claude"] } }));
65
65
  expect(scopedWindow(tied, { id: "claude-opus-4-6" })).toBeUndefined();
66
66
  });
67
+
68
+ // How long a pool's window runs, read off the provider's own key and name. Two things turn on it, which is why it is
69
+ // one function: a narrow column names the window by `short`, and a reading with no published reset may only be trusted
70
+ // for that long.
71
+
72
+ test("a window's length is read off the provider's own key, and off its name where the key says nothing", () => {
73
+ const period = (kind: string, label?: string) => windowPeriod(label === undefined ? { kind } : { kind, label });
74
+ expect(period("five_hour")).toEqual({ seconds: 5 * 3_600, short: "5h" });
75
+ expect(period("seven_day")).toEqual({ seconds: 7 * 86_400, short: "wk" });
76
+ expect(period("seven_day_opus")).toEqual({ seconds: 7 * 86_400, short: "wk" });
77
+ // Anthropic's own key says nothing; the label the reader built from it does.
78
+ expect(period("model:Fable", "Weekly · Fable")).toEqual({ seconds: 7 * 86_400, short: "wk" });
79
+ expect(period("google:gemini-weekly", "Gemini Models · Weekly Limit Remaining")).toEqual({ seconds: 7 * 86_400, short: "wk" });
80
+ expect(period("monthly", "Monthly · all models")).toEqual({ seconds: 30 * 86_400, short: "mo" });
81
+ expect(period("claude:12_hour")).toEqual({ seconds: 12 * 3_600, short: "12h" });
82
+ expect(period("daily")).toEqual({ seconds: 86_400, short: "24h" });
83
+ expect(period("30_minutes")).toEqual({ seconds: 1_800, short: "30m" });
84
+ // A pool nothing names the length of answers nothing, rather than guessing one.
85
+ expect(period("claude:tangelo")).toBeUndefined();
86
+ expect(period("model:Fable", "Fable")).toBeUndefined();
87
+ });
88
+
89
+ test("a reading is live until its reset, or, with none published, for one window's length after it was taken", () => {
90
+ const NOW = 1_700_000_000_000;
91
+ const HOUR = 3_600_000;
92
+ // A published reset is the authority whatever the window's length says, in both directions.
93
+ expect(windowLive(window({ kind: "five_hour", resetsAt: NOW / 1_000 + 60 }), NOW - 50 * HOUR, NOW)).toBe(true);
94
+ expect(windowLive(window({ kind: "seven_day", resetsAt: NOW / 1_000 - 1 }), NOW, NOW)).toBe(false);
95
+
96
+ // With none — what an idle five-hour pool is published with — the window's own length retires it.
97
+ const idle = window({ kind: "five_hour", utilization: 0 });
98
+ expect(windowLive(idle, NOW - 4 * HOUR, NOW)).toBe(true);
99
+ expect(windowLive(idle, NOW - 6 * HOUR, NOW)).toBe(false);
100
+ // A weekly pool read on the same morning is still describing the week it was read in.
101
+ expect(windowLive(window({ kind: "seven_day", utilization: 78 }), NOW - 12 * HOUR, NOW)).toBe(true);
102
+
103
+ // And a pool whose length nothing names has only its reset instant; without one it stands.
104
+ expect(windowLive(window({ kind: "claude:tangelo" }), NOW - 400 * HOUR, NOW)).toBe(true);
105
+ });
@@ -63,3 +63,72 @@ export const scopedWindow = (usage: AccountUsage | undefined, model: ModelRef):
63
63
  }
64
64
  return best.window;
65
65
  };
66
+
67
+ // How long a pool's window runs, read off the provider's own key and whatever name it published. One implementation,
68
+ // because two things need it and must agree: a narrow column names the window by `short`, and a reading with no
69
+ // published reset instant may only be trusted for `seconds` past the moment it was taken.
70
+
71
+ export interface WindowPeriod {
72
+ readonly seconds: number;
73
+ // The token a narrow column names this window by, e.g. "5h", "wk".
74
+ readonly short: string;
75
+ }
76
+
77
+ const HOUR_SECONDS = 3_600;
78
+ const DAY_SECONDS = 86_400;
79
+
80
+ /** Kind and label as space-padded lowercase words, underscores split, so `\b` matches across both spellings. */
81
+ export const periodWords = (pool: { readonly kind: string; readonly label?: string | undefined }): string =>
82
+ ` ${`${pool.kind} ${pool.label ?? ""}`
83
+ .toLowerCase()
84
+ .replaceAll(/[^a-z0-9]+/gu, " ")
85
+ .trim()} `;
86
+
87
+ // A count the words spell out, e.g. the 3 in "3 days".
88
+ const counted = (words: string, pattern: RegExp): number | undefined => {
89
+ const found = pattern.exec(words)?.[1];
90
+ return found === undefined ? undefined : Number(found);
91
+ };
92
+
93
+ // Day-scale and longer, where the named periods outrank a bare count: "7 days" is the week every plan sells, not a
94
+ // seven-day span of its own.
95
+ const dayPeriod = (words: string, days: number | undefined): WindowPeriod | undefined => {
96
+ if (days === 7 || /\bseven days?\b|\bweek(ly|s)?\b/u.test(words)) {
97
+ return { seconds: 7 * DAY_SECONDS, short: "wk" };
98
+ }
99
+ if (/\bmonth(ly|s)?\b/u.test(words)) {
100
+ return { seconds: 30 * DAY_SECONDS, short: "mo" };
101
+ }
102
+ if (days === 1 || /\bdaily\b/u.test(words)) {
103
+ return { seconds: DAY_SECONDS, short: "24h" };
104
+ }
105
+ return days === undefined ? undefined : { seconds: days * DAY_SECONDS, short: `${days}d` };
106
+ };
107
+
108
+ /** Window length behind a pool; undefined when neither the key nor the name says how long it runs. */
109
+ export const windowPeriod = (pool: { readonly kind: string; readonly label?: string | undefined }): WindowPeriod | undefined => {
110
+ const words = periodWords(pool);
111
+ const hours = counted(words, /\b(\d+) hours?\b/u);
112
+ // Hours first: a pool named both ("5 hours, weekly cap") runs on the shorter clock.
113
+ if (hours !== undefined || /\bfive hours?\b/u.test(words)) {
114
+ const span = hours ?? 5;
115
+ return { seconds: span * HOUR_SECONDS, short: `${span}h` };
116
+ }
117
+ const day = dayPeriod(words, counted(words, /\b(\d+) days?\b/u));
118
+ if (day !== undefined) {
119
+ return day;
120
+ }
121
+ const minutes = counted(words, /\b(\d+) minutes?\b/u);
122
+ return minutes === undefined ? undefined : { seconds: minutes * 60, short: `${minutes}m` };
123
+ };
124
+
125
+ // Whether a reading of this window can still be true. A published reset instant is the authority; with none, the
126
+ // window's own length is, since a pool read as empty says nothing about the window that opened after it. A window whose
127
+ // length nothing names keeps the old rule: only its reset instant can retire it.
128
+ export const windowLive = (window: UsageWindow, measuredAt: number, now: number): boolean => {
129
+ if (window.resetsAt !== undefined) {
130
+ return window.resetsAt * 1000 > now;
131
+ }
132
+ const period = windowPeriod(window);
133
+ return period === undefined || measuredAt + period.seconds * 1000 > now;
134
+ };
@@ -418,6 +418,12 @@ export type AgentWatch = NonNullable<AgentSummary["watches"]>[number];
418
418
  // needs that type declared first.
419
419
  export const AgentIdSchema = z.object({ id: z.string().min(1).describe("Which conversation.") });
420
420
 
421
+ // Naming no watch disarms every one, which is what a press made about the whole card means; a press made about one
422
+ // watch's own transcript row names it, and leaves the conversation's other watches armed.
423
+ export const AgentStopWatchingSchema = AgentIdSchema.extend({
424
+ watchId: z.string().optional().describe("Which watch to disarm. Absent disarms every watch this conversation is parked on."),
425
+ });
426
+
421
427
  // Pages a transcript: a read returns its most recent turns and where they start (`from`); handing that back as `before`
422
428
  // asks for the page above. A stale cursor (a rewind, a fork) is clamped, never refused.
423
429
  export const AgentTranscriptQuerySchema = AgentIdSchema.extend({
@@ -123,9 +123,14 @@ export type DeviceAgentFlowInput = z.infer<typeof DeviceAgentFlowInputSchema>;
123
123
  // `sync-clean` removes the build output left in directories the sandbox has deleted, which is what stops those
124
124
  // deletions from ever landing. It deletes only content the session already ignores, so it is the one command here whose
125
125
  // worst outcome is a rebuild — which is why it is a button and not a turn.
126
+ // `mirror-ignore`/`mirror-unignore` are `mirror-off`'s per-port form, and the reason they exist: a port number that is
127
+ // permanently taken on one machine's localhost has no answer in an all-or-nothing switch, so the choice lives where the
128
+ // conflict does — one port, one device, durable — instead of muting every port the pairing serves.
126
129
  export const DeviceCommandSchema = z.enum([
127
130
  "mirror-off",
128
131
  "mirror-on",
132
+ "mirror-ignore",
133
+ "mirror-unignore",
129
134
  "sync-pause",
130
135
  "sync-resume",
131
136
  "sync-unpair",
@@ -210,6 +215,8 @@ export const DeviceLocalDirSchema = z
210
215
  .min(1)
211
216
  .max(4096)
212
217
  .regex(/^(?:~|\/|[A-Za-z]:[\\/])[^"'`$;|&\n\r]*$/);
218
+ // A TCP port number, the one value a caller supplies that reaches a command line as a number rather than a string.
219
+ export const PortNumberSchema = z.number().int().min(1).max(65535);
213
220
  export const DeviceCommandInputSchema = z.object({
214
221
  id: z.string().min(1),
215
222
  command: DeviceCommandSchema,
@@ -218,6 +225,9 @@ export const DeviceCommandInputSchema = z.object({
218
225
  // for file sync, the folder on that device. The pairing token is minted by the daemon; no caller ever carries one.
219
226
  mode: z.enum(["sync", "mirror"]).optional(),
220
227
  localDir: DeviceLocalDirSchema.optional(),
228
+ // The two per-port mirror switches only. A number rather than a string, so nothing a caller sends can widen the
229
+ // command line it lands in.
230
+ port: PortNumberSchema.optional(),
221
231
  });
222
232
  export type DeviceCommandInput = z.infer<typeof DeviceCommandInputSchema>;
223
233
  // `ok` is the command's own exit status, not this route's: a refusal or non-zero exit is a real answer, not a thrown
@@ -294,9 +304,18 @@ export const DevicePortStateSchema = z.enum([
294
304
  "held-by-sandbox",
295
305
  // Something outside this product already binds the port; not ours to name or take.
296
306
  "busy",
307
+ // The owner told this device to leave this number alone (`sync mirror ignore`); nothing was attempted.
308
+ "ignored",
297
309
  ]);
310
+ export type DevicePortState = z.infer<typeof DevicePortStateSchema>;
311
+
312
+ // Why a port the sandbox serves is not on this machine's localhost: the state minus its one success. Named here
313
+ // because the machine decides it and the browser renders it, so a new reason has to reach both at once.
314
+ export const PortSkipReasonSchema = DevicePortStateSchema.exclude(["mirrored"]);
315
+ export type PortSkipReason = z.infer<typeof PortSkipReasonSchema>;
316
+
298
317
  export const DevicePortSchema = z.object({
299
- port: z.number().int().min(1).max(65535),
318
+ port: PortNumberSchema,
300
319
  host: z.enum(["127.0.0.1", "::1"]),
301
320
  // The sandbox serving the port, whose /ports listed it, not whoever ended up holding the local bind.
302
321
  sandboxId: z.string(),
@@ -0,0 +1,72 @@
1
+ // diff: one side of a file diff named by its source, for the routes that answer with something other than the text
2
+ // diff itself (bytes on /diff/raw, derived text on /diff/derived).
3
+ import { z } from "zod";
4
+ import { GitDiffSideSchema } from "./git/git.js";
5
+
6
+ const path = z.string().min(1).describe("The file, relative to the repo or scope the diff belongs to.");
7
+ const repo = z.string().min(1).describe('Which repository: "root" for the workspace itself, otherwise a repo id.');
8
+
9
+ // The four pairings a review surface lists a row under; every field is a string, so the same shape rides a query string
10
+ // and the JSON body of a typed call alike.
11
+ export const DiffSourceQuerySchema = z.discriminatedUnion("source", [
12
+ z
13
+ .object({
14
+ source: z.literal("working"),
15
+ repo,
16
+ side: GitDiffSideSchema.describe("Which git side the row came from; a half-staged file is two different diffs."),
17
+ path,
18
+ })
19
+ .describe("Uncommitted work in a workspace repo, what the Changes panel lists."),
20
+ z
21
+ .object({
22
+ source: z.literal("agent"),
23
+ agent: z.string().min(1).describe("The conversation whose work is under review."),
24
+ repo,
25
+ path,
26
+ })
27
+ .describe("One agent's work against the base its review is listed against."),
28
+ z
29
+ .object({
30
+ source: z.literal("commit"),
31
+ repo,
32
+ sha: z
33
+ .string()
34
+ .regex(/^[0-9a-f]{4,64}$/)
35
+ .describe("The commit, compared against its first parent."),
36
+ path,
37
+ })
38
+ .describe("A commit against its first parent."),
39
+ z
40
+ .object({
41
+ source: z.literal("checkpoint"),
42
+ snapshot: z.string().min(1).describe("Which saved point."),
43
+ scope: z.string().min(1).describe("Which part of the workspace the path belongs to."),
44
+ path,
45
+ })
46
+ .describe("A saved point in the timeline, against the visible one before it."),
47
+ ]);
48
+ export type DiffSourceQuery = z.infer<typeof DiffSourceQuerySchema>;
49
+
50
+ // One side of a document rendered to text, or why it could not be. The text is fileq's rendering, the same one an
51
+ // agent reads instead of the bytes, so what a reviewer compares here is what the agent worked from.
52
+ export const DerivedSideSchema = z.discriminatedUnion("present", [
53
+ z.object({
54
+ present: z.literal(true),
55
+ content: z.string().describe("The side as markdown."),
56
+ deriver: z.string().describe("Which reader made this text, with its version."),
57
+ notes: z.array(z.string()).describe("Every cap and degradation the conversion hit, one line each."),
58
+ truncated: z.boolean().describe("The rendering was longer than this response carries; only its start is here."),
59
+ }),
60
+ z.object({
61
+ present: z.literal(false),
62
+ reason: z.string().describe("Why this side has no text: a format nothing reads, a broken file, a sandbox with no reader."),
63
+ }),
64
+ ]);
65
+ export type DerivedSide = z.infer<typeof DerivedSideSchema>;
66
+
67
+ // Both sides as text; an absent side is a side the diff does not have (an added file has no before).
68
+ export const DerivedDiffSchema = z.object({
69
+ before: DerivedSideSchema.optional().describe("The file as it was, rendered to text. Absent when it did not exist yet."),
70
+ after: DerivedSideSchema.optional().describe("The file as it is now, rendered to text. Absent when it was deleted."),
71
+ });
72
+ export type DerivedDiff = z.infer<typeof DerivedDiffSchema>;