@intentic/sandbox-contract 1.167.0 → 1.168.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.
- package/dist/agent-catalog.d.ts +20 -2
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +80 -4
- package/dist/agent-catalog.js.map +1 -1
- package/dist/contracts/agent.contract.d.ts +36 -8
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.js +1 -2
- package/dist/contracts/agent.contract.js.map +1 -1
- package/dist/contracts/agents.contract.d.ts +37 -39
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +4 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +1 -39
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.d.ts +0 -2
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +56 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +7 -2
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/translator.contract.d.ts +36 -0
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/events.d.ts +88 -159
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +37 -4
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +185 -102
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +168 -23
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +41 -17
- package/dist/schemas.js.map +1 -1
- package/dist/title.d.ts.map +1 -1
- package/dist/title.js.map +1 -1
- package/dist/workspace-state.d.ts +9 -0
- package/dist/workspace-state.d.ts.map +1 -0
- package/dist/workspace-state.js +71 -0
- package/dist/workspace-state.js.map +1 -0
- package/package.json +2 -2
- package/src/agent-catalog.test.ts +118 -0
- package/src/agent-catalog.ts +179 -15
- package/src/contracts/agent.contract.ts +1 -14
- package/src/contracts/system.contract.ts +15 -1
- package/src/events.test.ts +32 -0
- package/src/events.ts +105 -27
- package/src/index.ts +1 -0
- package/src/schemas.test.ts +2 -8
- package/src/schemas.ts +136 -57
- package/src/title.ts +6 -2
- package/src/workspace-state.test.ts +129 -0
- package/src/workspace-state.ts +160 -0
package/src/agent-catalog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AgentHarness, type AgentProvider, type Model, NATIVE_PROVIDERS, type NativeProvider, type PermissionMode } from "./schemas.js";
|
|
2
2
|
|
|
3
3
|
/* The provider / harness / model catalog every picker shares (the chat menu, the automations dialog) — pure
|
|
4
4
|
* data keyed by the wire vocabulary in schemas.ts, so the surfaces can't drift. Live state stays with the
|
|
@@ -46,6 +46,23 @@ export const PROVIDER_ACCESS: Record<NativeProvider, ProviderAccess> = {
|
|
|
46
46
|
gemini: { kind: "free", requirement: "Google sign-in", runs: "Gemini, Claude and GPT-OSS under Claude Code" },
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/* WHOSE ALLOWANCE A TURN ON THIS PROVIDER SPENDS, as the subject of a sentence — a third naming of the same
|
|
50
|
+
* five ids, and the third is not redundancy. PROVIDERS names the RUNTIME the user picks ("Claude Code", "Kimi
|
|
51
|
+
* Code") and PROVIDER_ACCESS.requirement names the thing they CONNECT ("Claude subscription", "Google sign-in");
|
|
52
|
+
* neither reads as English in "… usage limit reached", and neither is what a spent quota belongs to.
|
|
53
|
+
*
|
|
54
|
+
* The routed providers are why this can't be inferred from the harness: a `gemini` turn drives Claude Opus 4.6
|
|
55
|
+
* through Google's Antigravity channel on a plain Google sign-in, so the quota that refuses it is Google's and
|
|
56
|
+
* Anthropic has no part in it. Saying "Claude usage limit reached" there sends the user to check the wrong
|
|
57
|
+
* account — and to a reset that is days out on a pool they never touched. */
|
|
58
|
+
export const PROVIDER_VENDOR: Record<NativeProvider, string> = {
|
|
59
|
+
claude: "Claude",
|
|
60
|
+
codex: "ChatGPT",
|
|
61
|
+
grok: "xAI",
|
|
62
|
+
kimi: "Kimi Code",
|
|
63
|
+
gemini: "Google",
|
|
64
|
+
};
|
|
65
|
+
|
|
49
66
|
// What a turn on this provider costs at the MARGIN, ordering the same three kinds by the only question a
|
|
50
67
|
// helper spending the user's money on their behalf has to answer: free is free; a subscription is already paid
|
|
51
68
|
// but has a quota the user watches; a key is metered, so every call is real money. Deliberately not folded into
|
|
@@ -60,6 +77,21 @@ export const accessFor = (provider: AgentProvider): ProviderAccess | undefined =
|
|
|
60
77
|
// static fallback.
|
|
61
78
|
export const providerLabel = (provider: AgentProvider): string => PROVIDERS.find((p) => p.value === provider)?.label ?? provider;
|
|
62
79
|
|
|
80
|
+
/* Whether a plan-limit reading for this provider is OBTAINABLE at all — one fact, on the wire, because both
|
|
81
|
+
* halves need it and they need the same answer. The daemon reads it to decide what to even ask upstream for
|
|
82
|
+
* (usage/translator-usage.ts); the browser reads it to say WHY an account shows no meter, which is the
|
|
83
|
+
* difference between "this plan publishes nothing" and "we haven't measured yet" — two states that look
|
|
84
|
+
* identical as a blank row and mean opposite things.
|
|
85
|
+
*
|
|
86
|
+
* Three can be read, by two mechanisms that stop at the daemon's readers: Claude's rides its own turn (the
|
|
87
|
+
* OAuth usage endpoint, agent.ts), ChatGPT's and Google's are pulled through the translator's
|
|
88
|
+
* credential-scoped api-call. Grok is absent because xAI's usable billing data needs a subject id CLIProxyAPI
|
|
89
|
+
* keeps out of its auth-file listing, and the fallback probe spends a token to answer. Kimi is absent because
|
|
90
|
+
* it publishes no quota endpoint — the bundled translator knows only its chat and OAuth routes. Adding either
|
|
91
|
+
* is adding a reader and its name here, and nothing else. */
|
|
92
|
+
export const PLAN_LIMIT_PROVIDERS: readonly NativeProvider[] = ["claude", "codex", "gemini"];
|
|
93
|
+
export const reportsPlanLimits = (provider: AgentProvider): boolean => PLAN_LIMIT_PROVIDERS.includes(provider as NativeProvider);
|
|
94
|
+
|
|
63
95
|
// The harness (agentic loop) a turn runs on, orthogonal to the provider. `native` = the provider's own runtime;
|
|
64
96
|
// `claude-code` = the Claude Code loop for any provider (codex/grok then route through the translator). Only
|
|
65
97
|
// surfaced for codex/grok — claude is always its own Claude Code loop, and kimi/gemini have no native runtime
|
|
@@ -69,20 +101,152 @@ export const HARNESSES: readonly { label: string; value: AgentHarness }[] = [
|
|
|
69
101
|
{ label: "Claude Code", value: "claude-code" },
|
|
70
102
|
];
|
|
71
103
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
/* WHAT A PROVIDER/HARNESS PAIR CAN ACTUALLY DO — one declaration, read by both sides of the wire.
|
|
105
|
+
*
|
|
106
|
+
* Four runtimes serve turns behind one seam (AgentRequest in, AgentEvent frames out): the Claude Code Agent SDK
|
|
107
|
+
* loop, Codex's exec surface, OpenCode, and any ACP agent. They do NOT do the same things, and for a long time
|
|
108
|
+
* the only thing that said so was a comment inside each adapter — "Ignores the Claude-only request fields" —
|
|
109
|
+
* which no surface above it could read. So the composer offered "Ask before each file edit" on a runtime whose
|
|
110
|
+
* every tool call is pre-approved, and offered a reasoning-effort scale to a runtime that drops the field.
|
|
111
|
+
*
|
|
112
|
+
* A capability is listed here only if something READS it: the daemon gates a seam on it, the composer hides or
|
|
113
|
+
* clamps a control by it, or `limitationsOf` tells the user about it. That is the whole point — an ability the
|
|
114
|
+
* matrix claims and nothing consults is how the drift started.
|
|
115
|
+
*
|
|
116
|
+
* Adding a provider is a row here, not a hunt for literals; agent-catalog.test.ts walks PROVIDERS × HARNESSES
|
|
117
|
+
* and demands one, so a pair can never be silently absent. */
|
|
118
|
+
export interface AgentCapabilities {
|
|
119
|
+
// Which agentic loop actually serves the turn — the question "is the harness `claude-code`" only looks like.
|
|
120
|
+
// Claude is always its own Claude Code loop, and kimi/gemini have no native runtime at all (both are
|
|
121
|
+
// re-served through the translator), so all three run it whatever harness the client sent; only codex/grok
|
|
122
|
+
// have a native runtime to switch away from. Names the session store a finished conversation's transcript is
|
|
123
|
+
// backfilled from, too.
|
|
124
|
+
readonly runtime: "claude-code" | "codex" | "opencode" | "acp";
|
|
125
|
+
// Mid-turn injection (the SteeringQueue behind /agent/steer). Needs the SDK's streaming-input mode.
|
|
126
|
+
readonly steering: boolean;
|
|
127
|
+
// How much of the permission-mode axis the runtime honours. "modes" = every PermissionMode, with per-tool
|
|
128
|
+
// permission cards and `mode` frames when the agent moves itself; "plan" = propose-then-approve or run, and
|
|
129
|
+
// nothing in between — the container is the isolation boundary and every tool call is pre-approved.
|
|
130
|
+
readonly permissions: "modes" | "plan";
|
|
131
|
+
// Can stop mid-turn and ask the user a multiple-choice question (`question` frames).
|
|
132
|
+
readonly questions: boolean;
|
|
133
|
+
// Which of the turn's tools reach the agent. "full" = http MCP tools + in-process SDK servers + plugin
|
|
134
|
+
// checkouts + the browser servers; "http" = the http MCP tools alone, and only if the agent advertises http
|
|
135
|
+
// MCP support; "none" = the runtime has no seam for them at all.
|
|
136
|
+
readonly mcp: "full" | "http" | "none";
|
|
137
|
+
// Reasoning-effort selection is forwarded to the model.
|
|
138
|
+
readonly effort: boolean;
|
|
139
|
+
// How an isolated conversation's worktree is enforced. "namespace" = the worktree IS /work inside the turn's
|
|
140
|
+
// mount namespace (with the tool-input rewrite as the fallback when the container can't build one); "cwd" =
|
|
141
|
+
// the turn is merely cwd'd into the worktree, so an absolute /work path still reaches the shared checkout —
|
|
142
|
+
// which is why those turns are told where their tree is (turn-preamble.ts).
|
|
143
|
+
readonly isolation: "namespace" | "cwd";
|
|
144
|
+
// Publishes its slash commands (`commands` frames) for the composer's `/` popover.
|
|
145
|
+
readonly commands: boolean;
|
|
146
|
+
// Runs its shell in a tmux session the terminal panel can attach to (`terminal` frames).
|
|
147
|
+
readonly terminals: boolean;
|
|
148
|
+
// Fails with the coded frames the daemon's auto-resume keys off (rate_limit, provider-outage), so a turn the
|
|
149
|
+
// provider killed is re-run once the breaker says the provider is back (turn-resume.ts).
|
|
150
|
+
readonly recovery: boolean;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The Claude Code Agent SDK loop — the ceiling every other runtime is measured against, and the only one that
|
|
154
|
+
// owns the whole request: permission callbacks, the ask tool, plugins, hooks, and the spawn seam a mount
|
|
155
|
+
// namespace needs.
|
|
156
|
+
const CLAUDE_CODE: AgentCapabilities = {
|
|
157
|
+
runtime: "claude-code",
|
|
158
|
+
steering: true,
|
|
159
|
+
permissions: "modes",
|
|
160
|
+
questions: true,
|
|
161
|
+
mcp: "full",
|
|
162
|
+
effort: true,
|
|
163
|
+
isolation: "namespace",
|
|
164
|
+
commands: true,
|
|
165
|
+
terminals: true,
|
|
166
|
+
recovery: true,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Codex's exec surface: item-level events, no approval channel, no MCP seam through the SDK constructor we use.
|
|
170
|
+
// Reasoning effort IS forwarded (modelReasoningEffort). `codex app-server` is the upgrade path for the first two.
|
|
171
|
+
const CODEX: AgentCapabilities = {
|
|
172
|
+
runtime: "codex",
|
|
173
|
+
steering: false,
|
|
174
|
+
permissions: "plan",
|
|
175
|
+
questions: false,
|
|
176
|
+
mcp: "none",
|
|
177
|
+
effort: true,
|
|
178
|
+
isolation: "cwd",
|
|
179
|
+
commands: false,
|
|
180
|
+
terminals: false,
|
|
181
|
+
recovery: false,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// OpenCode (the Grok runtime): its own agentic loop, its own tools, allow-all permissions. It takes a model id
|
|
185
|
+
// and a prompt — no effort scale, no tools of ours, no command list.
|
|
186
|
+
const OPENCODE: AgentCapabilities = {
|
|
187
|
+
runtime: "opencode",
|
|
188
|
+
steering: false,
|
|
189
|
+
permissions: "plan",
|
|
190
|
+
questions: false,
|
|
191
|
+
mcp: "none",
|
|
192
|
+
effort: false,
|
|
193
|
+
isolation: "cwd",
|
|
194
|
+
commands: false,
|
|
195
|
+
terminals: false,
|
|
196
|
+
recovery: false,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Any agent speaking the Agent Client Protocol: a documented floor rather than the native ceiling. It publishes
|
|
200
|
+
// commands, runs its terminals in the conversation's tmux session, and takes our http MCP tools when it says it
|
|
201
|
+
// can — but it owns its own model, effort and permission posture.
|
|
202
|
+
const ACP: AgentCapabilities = {
|
|
203
|
+
runtime: "acp",
|
|
204
|
+
steering: false,
|
|
205
|
+
permissions: "plan",
|
|
206
|
+
questions: false,
|
|
207
|
+
mcp: "http",
|
|
208
|
+
effort: false,
|
|
209
|
+
isolation: "cwd",
|
|
210
|
+
commands: true,
|
|
211
|
+
terminals: true,
|
|
212
|
+
recovery: false,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// The pair → its record. An id that names no native provider is an installed `agent`-kind capability, served
|
|
216
|
+
// over ACP.
|
|
217
|
+
export const capabilitiesOf = (provider: AgentProvider, harness: AgentHarness): AgentCapabilities => {
|
|
218
|
+
if (provider === "codex") {
|
|
219
|
+
return harness === "claude-code" ? CLAUDE_CODE : CODEX;
|
|
220
|
+
}
|
|
221
|
+
if (provider === "grok") {
|
|
222
|
+
return harness === "claude-code" ? CLAUDE_CODE : OPENCODE;
|
|
223
|
+
}
|
|
224
|
+
return (NATIVE_PROVIDERS as readonly string[]).includes(provider) ? CLAUDE_CODE : ACP;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Which permission modes a runtime can actually be put in. Under "plan" every other mode collapses onto the
|
|
228
|
+
// autonomous posture the runtime already runs, so offering them would be offering four names for two behaviours.
|
|
229
|
+
export const modesFor = (capabilities: AgentCapabilities): readonly PermissionMode[] =>
|
|
230
|
+
capabilities.permissions === "modes" ? ["default", "acceptEdits", "plan", "bypassPermissions"] : ["plan", "bypassPermissions"];
|
|
231
|
+
|
|
232
|
+
// The mode a selection falls back to when the runtime can't hold it — the same shape as clampEffort, and for the
|
|
233
|
+
// same reason: a provider switch must not leave the composer showing a posture nothing applies.
|
|
234
|
+
export const clampMode = (mode: PermissionMode, capabilities: AgentCapabilities): PermissionMode =>
|
|
235
|
+
modesFor(capabilities).includes(mode) ? mode : "bypassPermissions";
|
|
236
|
+
|
|
237
|
+
// What this pair does NOT do, phrased for the person about to send a message to it — the honest half of the
|
|
238
|
+
// picker, and the reason the record carries axes the daemon itself never branches on. Empty ⇒ the full ceiling.
|
|
239
|
+
export const limitationsOf = (capabilities: AgentCapabilities): string[] => [
|
|
240
|
+
...(capabilities.permissions === "plan" ? ["no per-tool approvals"] : []),
|
|
241
|
+
...(capabilities.questions ? [] : ["no clarifying questions"]),
|
|
242
|
+
...(capabilities.steering ? [] : ["no mid-turn steering"]),
|
|
243
|
+
...(capabilities.mcp === "none" ? ["no MCP tools or plugins"] : capabilities.mcp === "http" ? ["MCP tools only — no plugins or browser"] : []),
|
|
244
|
+
...(capabilities.effort ? [] : ["no effort control"]),
|
|
245
|
+
...(capabilities.commands ? [] : ["no slash commands"]),
|
|
246
|
+
...(capabilities.terminals ? [] : ["no terminal panel"]),
|
|
247
|
+
...(capabilities.isolation === "namespace" ? [] : ["worktree by working directory only"]),
|
|
248
|
+
...(capabilities.recovery ? [] : ["no auto-resume after an outage"]),
|
|
249
|
+
];
|
|
86
250
|
|
|
87
251
|
// Claude's compile-time model floor, shared by the daemon's catalog (claude-models.ts — its last rung, reached
|
|
88
252
|
// only before either live source has ever answered) and by the web's pre-load list, so the two can't name
|
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import { eventIterator, oc } from "@orpc/contract";
|
|
2
2
|
import { AgentCommandsQuerySchema, AgentCommandsSchema, AttachFrameSchema } from "../events.js";
|
|
3
|
-
import {
|
|
4
|
-
AgentReplySchema,
|
|
5
|
-
AgentTurnSchema,
|
|
6
|
-
AttachTurnSchema,
|
|
7
|
-
OkSchema,
|
|
8
|
-
ResumeLimitSchema,
|
|
9
|
-
StartedTurnSchema,
|
|
10
|
-
SteerSchema,
|
|
11
|
-
StopTurnSchema,
|
|
12
|
-
} from "../schemas.js";
|
|
3
|
+
import { AgentReplySchema, AgentTurnSchema, AttachTurnSchema, OkSchema, StartedTurnSchema, SteerSchema, StopTurnSchema } from "../schemas.js";
|
|
13
4
|
|
|
14
5
|
// A turn EXECUTES as a detached daemon-side run: `run` starts it and acks with the run id; any number of
|
|
15
6
|
// clients render it via `attach` (replay from a seq cursor, then live) — the initiating window holds no
|
|
@@ -22,10 +13,6 @@ export const agentContract = {
|
|
|
22
13
|
reply: oc.route({ method: "POST", path: "/agent/reply" }).input(AgentReplySchema).output(OkSchema),
|
|
23
14
|
steer: oc.route({ method: "POST", path: "/agent/steer" }).input(SteerSchema).output(OkSchema),
|
|
24
15
|
stop: oc.route({ method: "POST", path: "/agent/stop" }).input(StopTurnSchema).output(OkSchema),
|
|
25
|
-
// Fire the conversation's pending usage-limit resume immediately, optionally on another account — the
|
|
26
|
-
// chat's "resume on another account" action. Acks with the run id, exactly like `run`; the resumed turn
|
|
27
|
-
// is an ordinary detached run any window attaches to.
|
|
28
|
-
resumeLimit: oc.route({ method: "POST", path: "/agent/resume-limit" }).input(ResumeLimitSchema).output(StartedTurnSchema),
|
|
29
16
|
// The provider's slash commands as last published by one of its turns, so a conversation's `/` popover is
|
|
30
17
|
// populated before it has run one. The live `commands` frame stays authoritative for a running turn.
|
|
31
18
|
commands: oc.route({ method: "GET", path: "/agent/commands" }).input(AgentCommandsQuerySchema).output(AgentCommandsSchema),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { eventIterator, oc } from "@orpc/contract";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { SystemEventSchema } from "../events.js";
|
|
3
|
+
import { SessionTranscriptSchema, SystemEventSchema } from "../events.js";
|
|
4
4
|
import {
|
|
5
5
|
BrowserNameParamSchema,
|
|
6
6
|
BrowsersListSchema,
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
InfoSchema,
|
|
11
11
|
OkSchema,
|
|
12
12
|
PresenceReportSchema,
|
|
13
|
+
SubagentIdParamSchema,
|
|
14
|
+
SubagentsListSchema,
|
|
13
15
|
TerminalNameParamSchema,
|
|
14
16
|
TerminalsListSchema,
|
|
15
17
|
UsageSummarySchema,
|
|
@@ -47,4 +49,16 @@ export const systemContract = {
|
|
|
47
49
|
// which is the honest account of the owner pulling the plug.
|
|
48
50
|
browsers: oc.route({ method: "GET", path: "/system/browsers" }).output(BrowsersListSchema),
|
|
49
51
|
closeBrowser: oc.route({ method: "DELETE", path: "/system/browsers/{name}" }).input(BrowserNameParamSchema).output(OkSchema),
|
|
52
|
+
// The agents this sandbox's agents started — SDK subagents and delegated Codex/Grok runs alike (see
|
|
53
|
+
// SubagentSessionSchema). Same two-route shape as the browsers above, and same division of labour: the list
|
|
54
|
+
// is polled by the Subagents area while it is on screen and loosely by the rail, so its tile can appear the
|
|
55
|
+
// moment a turn delegates. There is no third WebSocket here, because a subagent has no byte stream to watch —
|
|
56
|
+
// what you watch it through is its TRANSCRIPT, which `subagentTranscript` serves in the one shape every
|
|
57
|
+
// other transcript route already answers in: live from the parent turn's frame log while it runs, off the
|
|
58
|
+
// provider's own store once it has finished.
|
|
59
|
+
subagents: oc.route({ method: "GET", path: "/system/subagents" }).output(SubagentsListSchema),
|
|
60
|
+
subagentTranscript: oc
|
|
61
|
+
.route({ method: "GET", path: "/system/subagents/{id}/transcript" })
|
|
62
|
+
.input(SubagentIdParamSchema)
|
|
63
|
+
.output(SessionTranscriptSchema),
|
|
50
64
|
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { RESUME_NOTES, withResumeNote, withoutResumeNote } from "./events.js";
|
|
3
|
+
|
|
4
|
+
/* The resume note is a round trip across the wire: the daemon wraps a prompt to tell the model what interrupted
|
|
5
|
+
* it, and the client unwraps the SAME prompt off an attach head to tell whether it already has that bubble. A
|
|
6
|
+
* mismatch between the two halves fails silently and cosmetically — a paragraph of machine prose rendered as
|
|
7
|
+
* something the user typed — which is exactly the kind of drift that stays broken. */
|
|
8
|
+
test("a resume note round-trips back to the user's own words", () => {
|
|
9
|
+
for (const note of Object.values(RESUME_NOTES)) {
|
|
10
|
+
expect(withoutResumeNote(withResumeNote("ship the parser", note))).toBe("ship the parser");
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// A prompt with blank lines of its own: only the note's own separator comes off, never the user's paragraphs.
|
|
15
|
+
test("stripping takes the note and nothing of the prompt", () => {
|
|
16
|
+
const prompt = "step one\n\nstep two\n\nstep three";
|
|
17
|
+
expect(withoutResumeNote(withResumeNote(prompt, RESUME_NOTES.outage))).toBe(prompt);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// An ordinary prompt passes through untouched, so every attach head can be handed through it.
|
|
21
|
+
test("a prompt that is not a resume is left alone", () => {
|
|
22
|
+
expect(withoutResumeNote("just a question")).toBe("just a question");
|
|
23
|
+
expect(withoutResumeNote("")).toBe("");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Wrapping is idempotent: a resume that dies the same way again is re-recorded from its own input, and a second
|
|
27
|
+
// note stacked on the first would grow the prompt on every attempt.
|
|
28
|
+
test("wrapping an already-wrapped prompt adds nothing", () => {
|
|
29
|
+
const once = withResumeNote("retry me", RESUME_NOTES.restart);
|
|
30
|
+
expect(withResumeNote(once, RESUME_NOTES.restart)).toBe(once);
|
|
31
|
+
expect(withResumeNote(once, RESUME_NOTES.auth)).toBe(once);
|
|
32
|
+
});
|
package/src/events.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
LandConflictSchema,
|
|
7
7
|
PermissionModeSchema,
|
|
8
8
|
RateLimitInfoSchema,
|
|
9
|
+
SubagentKindSchema,
|
|
10
|
+
SubagentStatusSchema,
|
|
9
11
|
UsageWindowSchema,
|
|
10
12
|
} from "./schemas.js";
|
|
11
13
|
|
|
@@ -125,19 +127,36 @@ export type ToolCallContent = z.infer<typeof ToolCallContentSchema>;
|
|
|
125
127
|
// tool_use/tool_result blocks, so a restored card carries everything the live `tool_call` frame did except
|
|
126
128
|
// the streaming-only correlation fields.
|
|
127
129
|
//
|
|
128
|
-
// One restored tool card.
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
130
|
+
// One restored tool card. A subagent's own calls and its thinking nest under the Agent card that spawned them,
|
|
131
|
+
// the same two fields (and the same recursion) the live ChatTool carries — so a reopened chat redraws the
|
|
132
|
+
// delegation it was showing instead of a leaf card with the whole child collapsed into its result text.
|
|
133
|
+
// z.lazy because the shape refers to itself: a subagent that delegates nests one level deeper.
|
|
134
|
+
export const RestoredToolCallSchema: z.ZodType<RestoredToolCall> = z.lazy(() =>
|
|
135
|
+
z.object({
|
|
136
|
+
id: z.string(),
|
|
137
|
+
name: z.string(),
|
|
138
|
+
category: ToolKindSchema,
|
|
139
|
+
status: ToolCallStatusSchema,
|
|
140
|
+
target: z.string().optional(),
|
|
141
|
+
locations: z.array(ToolCallLocationSchema).optional(),
|
|
142
|
+
content: z.array(ToolCallContentSchema).optional(),
|
|
143
|
+
children: z.array(RestoredToolCallSchema).optional(),
|
|
144
|
+
thinking: z.string().optional(),
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
// Mutable, unlike most of this file: both builders settle a card IN PLACE when its result arrives turns later
|
|
148
|
+
// (restoredTurn's `cards` map, readWorkspaceSession's `awaiting`), which is what saves them a second pass.
|
|
149
|
+
export interface RestoredToolCall {
|
|
150
|
+
id: string;
|
|
151
|
+
name: string;
|
|
152
|
+
category: ToolKind;
|
|
153
|
+
status: ToolCallStatus;
|
|
154
|
+
target?: string | undefined;
|
|
155
|
+
locations?: ToolCallLocation[] | undefined;
|
|
156
|
+
content?: ToolCallContent[] | undefined;
|
|
157
|
+
children?: RestoredToolCall[] | undefined;
|
|
158
|
+
thinking?: string | undefined;
|
|
159
|
+
}
|
|
141
160
|
|
|
142
161
|
// One restored bubble. Each stored assistant message becomes its own, which is what reproduces the live
|
|
143
162
|
// interleaving — prose, the tool cards that prose introduced, then the next block of prose — rather than
|
|
@@ -162,7 +181,8 @@ export const AgentTranscriptSchema = SessionTranscriptSchema.extend({ sessionId:
|
|
|
162
181
|
// without a UI mapping is dropped. `plan`/`question`/`permission` pause the turn until the user answers on the
|
|
163
182
|
// `POST /agent/reply` side channel, and `resolved` releases the one it names; `mode` reports the live
|
|
164
183
|
// permission posture as the agent changes it.
|
|
165
|
-
// `parentToolUseId` tags frames produced inside a subagent (Task tool)
|
|
184
|
+
// `parentToolUseId` tags frames produced inside a subagent (Task tool); `subagent`/`subagent_update` report the
|
|
185
|
+
// subagent itself, keyed by the same tool_use id those tagged frames carry.
|
|
166
186
|
export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
167
187
|
z.object({ kind: z.literal("session"), sessionId: z.string() }),
|
|
168
188
|
/* First frame of an isolated turn: the conversation's worktree identity — its branch (agent/<id>) and the
|
|
@@ -229,6 +249,40 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
229
249
|
// `browser-<id>` session, and the client surfaces it in the same panel as the terminals. One per turn, for
|
|
230
250
|
// the same reason: one browser serves every browser call the turn makes.
|
|
231
251
|
z.object({ kind: z.literal("browser"), session: z.string() }),
|
|
252
|
+
/* THE AGENT STARTED ANOTHER AGENT — an Agent/Task subagent, or a Codex/Grok CLI it drove from its own Bash
|
|
253
|
+
* (see SubagentSessionSchema). One `subagent` frame per child, then `subagent_update` as it works: the same
|
|
254
|
+
* call/update pair `tool_call`/`tool_call_update` uses, and for the same reason — the fields that move
|
|
255
|
+
* (status, spend, what it is doing) arrive many times and must REPLACE, while the fields that identify it are
|
|
256
|
+
* said once.
|
|
257
|
+
*
|
|
258
|
+
* `id` is the SPAWNING TOOL CALL's id — the same id the client already nests the child's inner frames under
|
|
259
|
+
* (`parentToolUseId`), so both frames land on the card that spawned the child by the lookup that is already
|
|
260
|
+
* there (mapToolAnywhere). No second correlation, and nothing to get wrong.
|
|
261
|
+
*
|
|
262
|
+
* These exist because the SDK's task messages were dropped. A BACKGROUNDED child (the Agent tool's default)
|
|
263
|
+
* emits its tool_use and then nothing until its result lands, which for a long child is minutes of a spinner
|
|
264
|
+
* that cannot say whether anything is happening. */
|
|
265
|
+
z.object({
|
|
266
|
+
kind: z.literal("subagent"),
|
|
267
|
+
id: z.string(),
|
|
268
|
+
subagentKind: SubagentKindSchema,
|
|
269
|
+
agentType: z.string().optional(),
|
|
270
|
+
description: z.string().optional(),
|
|
271
|
+
model: z.string().optional(),
|
|
272
|
+
background: z.boolean().optional(),
|
|
273
|
+
// A delegation's tmux session — the one live view a subagent doesn't have (SubagentSessionSchema).
|
|
274
|
+
terminal: z.string().optional(),
|
|
275
|
+
}),
|
|
276
|
+
z.object({
|
|
277
|
+
kind: z.literal("subagent_update"),
|
|
278
|
+
id: z.string(),
|
|
279
|
+
status: SubagentStatusSchema.optional(),
|
|
280
|
+
tokens: z.number().optional(),
|
|
281
|
+
toolUses: z.number().optional(),
|
|
282
|
+
lastTool: z.string().optional(),
|
|
283
|
+
summary: z.string().optional(),
|
|
284
|
+
error: z.string().optional(),
|
|
285
|
+
}),
|
|
232
286
|
z.object({ kind: z.literal("todos"), items: z.array(TodoItemSchema) }),
|
|
233
287
|
// The provider's own slash commands (ACP available_commands_update), replaced whole each time — the
|
|
234
288
|
// composer's `/` popover lists them; invoking one is plain `/name …` prompt text (the ACP convention).
|
|
@@ -322,9 +376,11 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
322
376
|
"claude-reauth",
|
|
323
377
|
// The API refused this turn's token MID-FLIGHT — nearly always one superseded by a rotation,
|
|
324
378
|
// which Anthropic retires the moment its successor is minted. Distinct from claude-reauth: the
|
|
325
|
-
// account is fine and the daemon re-mints on the spot, so this
|
|
326
|
-
// that
|
|
327
|
-
//
|
|
379
|
+
// account is fine and the daemon re-mints on the spot, so this is usually a notice about a turn
|
|
380
|
+
// that is coming back rather than a request for the user to do anything. `autoResume` says
|
|
381
|
+
// which of the two: "scheduled" means the re-mint-and-re-run is armed, and its absence means
|
|
382
|
+
// nothing is coming (the turn was already a resume, or it ran on a credential with nothing to
|
|
383
|
+
// re-mint from) — that is the case where reconnecting really is the fix.
|
|
328
384
|
"claude-token-refused",
|
|
329
385
|
// The model provider itself failed transiently — 500/502/503, a 529 at capacity, a dropped
|
|
330
386
|
// socket — and the harness's own in-turn retries did not outlast it. Nothing about the workspace
|
|
@@ -346,12 +402,12 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
346
402
|
// rate_limit_event or the account's persisted usage windows). Absent when the reset instant is unknown
|
|
347
403
|
// (nothing to schedule against).
|
|
348
404
|
resetsAt: z.number().optional(),
|
|
349
|
-
// Where the daemon's resume of THIS turn stands
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// the
|
|
353
|
-
//
|
|
354
|
-
//
|
|
405
|
+
// Where the daemon's resume of THIS turn stands, for the two codes that have one (provider-outage,
|
|
406
|
+
// claude-token-refused). "scheduled" = the resume is armed and this turn comes back by itself;
|
|
407
|
+
// "available" = the daemon remembered the failed turn and turning resumeAfterOutage on arms that same
|
|
408
|
+
// resume, which is what the chat's offer banner hangs off — outage only, since a renewal is never gated
|
|
409
|
+
// on a setting. Absent means there is nothing automatic to resume: a spent usage limit never has one,
|
|
410
|
+
// and a refused credential has none once re-minting it has already been tried and failed.
|
|
355
411
|
autoResume: z.enum(["scheduled", "available"]).optional(),
|
|
356
412
|
/* provider-outage only: the shape of the wait. `retryAt` (epoch seconds) is when the next attempt is
|
|
357
413
|
* due — not a fixed cadence, because an outage has no reset instant to aim at and hammering a provider
|
|
@@ -362,10 +418,6 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
|
|
|
362
418
|
* on-by-default retry that gives no account of how long it will keep going is the kind users switch off
|
|
363
419
|
* defensively; one that says "attempt 2 of 6" is one they leave on. */
|
|
364
420
|
outage: z.object({ retryAt: z.number(), attempt: z.number(), maxAttempts: z.number() }).optional(),
|
|
365
|
-
// rate_limit only: the account whose allowance is spent, as the DAEMON resolved it (the client's own
|
|
366
|
-
// selection can be empty, which means "the provider's first"). It is what lets the chat offer the
|
|
367
|
-
// provider's OTHER accounts as a resume-now instead of a wait — see /agent/resume-limit.
|
|
368
|
-
account: z.string().optional(),
|
|
369
421
|
}),
|
|
370
422
|
z.object({ kind: z.literal("done") }),
|
|
371
423
|
]);
|
|
@@ -384,6 +436,32 @@ export const AttachFrameSchema = z.discriminatedUnion("kind", [
|
|
|
384
436
|
]);
|
|
385
437
|
export type AttachFrame = z.infer<typeof AttachFrameSchema>;
|
|
386
438
|
|
|
439
|
+
/* WHAT A RESUMED TURN'S PROMPT SAYS IT IS. The daemon re-runs a turn something underneath it killed (turn-resume.ts)
|
|
440
|
+
* by sending the original prompt again behind one of these sentences, so the model knows what interrupted it.
|
|
441
|
+
*
|
|
442
|
+
* They live on the wire rather than in the daemon because the CLIENT has to recognise them too: an attach head
|
|
443
|
+
* carries the run's prompt verbatim, and a window joining a resumed run would otherwise render the note as a
|
|
444
|
+
* message the USER wrote — the same words the user already said one run up, with a machine's preamble on them.
|
|
445
|
+
* Recognising the prefix is what lets that window reuse the bubble that is already there instead. */
|
|
446
|
+
export const RESUME_NOTES = {
|
|
447
|
+
auth: "The Claude credential that interrupted this conversation has been renewed, and this turn resumed automatically.",
|
|
448
|
+
outage: "The model provider was briefly unavailable and interrupted this conversation; this turn resumed automatically.",
|
|
449
|
+
restart: "The sandbox restarted while this turn was running, which stopped it, and this turn resumed automatically once it came back.",
|
|
450
|
+
} as const;
|
|
451
|
+
|
|
452
|
+
// The prompt a resume actually sends: the note, then why the words below are being repeated, then them.
|
|
453
|
+
export const withResumeNote = (prompt: string, note: string): string =>
|
|
454
|
+
Object.values(RESUME_NOTES).some((known) => prompt.startsWith(known))
|
|
455
|
+
? prompt
|
|
456
|
+
: `${note} The interrupted request is repeated below — where part of it was already completed in this session, continue from that point instead of starting over.\n\n${prompt}`;
|
|
457
|
+
|
|
458
|
+
// The user's own words inside a resumed prompt — the note and its explanation stripped back off. Returns the
|
|
459
|
+
// prompt unchanged when it is not a resume, so a caller can hand every attach head through it.
|
|
460
|
+
export const withoutResumeNote = (prompt: string): string => {
|
|
461
|
+
const note = Object.values(RESUME_NOTES).find((known) => prompt.startsWith(known));
|
|
462
|
+
return note === undefined ? prompt : prompt.slice(prompt.indexOf("\n\n") + 2);
|
|
463
|
+
};
|
|
464
|
+
|
|
387
465
|
// One parsed line from `intentic … --output ndjson` (engine events, provider `log`, the terminal `result`).
|
|
388
466
|
// Open-ended by design — the sandbox consumes the wire shape, not @intentic/engine's types — so a string
|
|
389
467
|
// `kind` plus arbitrary extra fields pass through. The apply-events tail (intentic.contract `applyEvents`) rides
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ export * from "./effects.js";
|
|
|
67
67
|
export * from "./events.js";
|
|
68
68
|
export * from "./sse.js";
|
|
69
69
|
export * from "./routes.js";
|
|
70
|
+
export * from "./workspace-state.js";
|
|
70
71
|
export * from "./agent-catalog.js";
|
|
71
72
|
export * from "./hostnames.js";
|
|
72
73
|
export * from "./model-order.js";
|
package/src/schemas.test.ts
CHANGED
|
@@ -29,7 +29,6 @@ test("a payload from a build that predates a toggle parses, with the new toggle
|
|
|
29
29
|
quickModel: "",
|
|
30
30
|
agentRetentionDays: 3,
|
|
31
31
|
autoLand: true,
|
|
32
|
-
autoResumeOnLimit: false,
|
|
33
32
|
resumeAfterOutage: true,
|
|
34
33
|
autoResumeOnRestart: true,
|
|
35
34
|
gateCommand: "",
|
|
@@ -68,9 +67,8 @@ test("an empty object is the full default settings object", () => {
|
|
|
68
67
|
// On because it is the historical behaviour — defaulting off would silently hold every existing
|
|
69
68
|
// sandbox's finished work on branches nobody is watching.
|
|
70
69
|
autoLand: true,
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
// committed, and the turns it saves are the unattended ones nobody is watching to restart by hand.
|
|
70
|
+
// On, where a spent usage limit re-runs nothing: an outage resume spends nothing the dead turn hadn't
|
|
71
|
+
// already committed, and the turns it saves are the unattended ones nobody is watching to restart by hand.
|
|
74
72
|
resumeAfterOutage: true,
|
|
75
73
|
// On: a daemon restart is usually intentic's own doing (an image update, an approved environment
|
|
76
74
|
// change), not the user's decision, so the turn it interrupted resumes rather than staying stuck.
|
|
@@ -90,7 +88,3 @@ test("a key of the wrong type is still a parse failure — tolerance is for abse
|
|
|
90
88
|
// The prompt cap is a real bound, not advice: the text IS the system prompt, and every turn pays for it.
|
|
91
89
|
expect(SandboxSettingsSchema.safeParse({ systemPrompt: "x".repeat(20001) }).success).toBe(false);
|
|
92
90
|
});
|
|
93
|
-
|
|
94
|
-
test("usage-limit auto-resume stays off while the feature is disabled, including for an older saved true value", () => {
|
|
95
|
-
expect(SandboxSettingsSchema.parse({ autoResumeOnLimit: true }).autoResumeOnLimit).toBe(false);
|
|
96
|
-
});
|