glm-coding-router 1.1.2 → 2.1.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/LICENSE +21 -21
- package/README.md +542 -426
- package/dist/bin/glm-review.js +28 -3
- package/dist/bin/glm-worker.js +30 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +46 -3
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/doctor-auth.js +107 -0
- package/dist/commands/doctor-command.js +171 -41
- package/dist/commands/landing.js +47 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/status.js +28 -15
- package/dist/commands/usage.js +34 -58
- package/dist/commands/watch.js +289 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/key-inspector.js +45 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process.js +83 -0
- package/dist/core/prompt.js +18 -5
- package/dist/core/routing-flags.js +59 -0
- package/dist/core/user-env.js +17 -7
- package/dist/core/zai-quota.js +148 -0
- package/dist/events/bus.js +64 -0
- package/dist/events/claude-adapter.js +416 -0
- package/dist/events/types.js +9 -0
- package/dist/handoff/bundle.js +203 -0
- package/dist/handoff/parent-handoff.js +48 -0
- package/dist/mcp/server.js +45 -1
- package/dist/routing/glm-routing.js +131 -0
- package/dist/runs/checkpoint.js +204 -0
- package/dist/runs/drain.js +165 -0
- package/dist/runs/heartbeat.js +45 -0
- package/dist/runs/registry.js +350 -0
- package/dist/runs/store.js +186 -0
- package/dist/runs/ulid.js +112 -0
- package/dist/runs/worker-run.js +672 -0
- package/dist/templates/agents-block.js +53 -44
- package/dist/templates/claude-block.js +56 -47
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/command-ui.js +158 -0
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +144 -0
- package/package.json +1 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { logger, redact } from "../core/logging.js";
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code `--output-format stream-json` adapter (specs/v2-architecture.md,
|
|
4
|
+
* Phase A).
|
|
5
|
+
*
|
|
6
|
+
* Maps NDJSON stdout lines — and retry-looking stderr lines — onto the
|
|
7
|
+
* canonical event model as UNSTAMPED `EventInput[]`: the bus is the only
|
|
8
|
+
* authority for `runId`/`taskId`/`provider`/`role`/`seq`/`ts`, so a producer
|
|
9
|
+
* physically cannot forge a sequence number.
|
|
10
|
+
*
|
|
11
|
+
* The stream-json schema is not a stable public API (A0), so the mapper is
|
|
12
|
+
* defensive by construction: any unrecognized `type`, missing field or wrong
|
|
13
|
+
* shape yields zero events plus one debug log — never a throw. A malformed
|
|
14
|
+
* line must not kill a run; that is the single most important property of
|
|
15
|
+
* this file, and even an adapter bug degrades the same way (see the
|
|
16
|
+
* try/catch in {@link adaptClaudeMessage}).
|
|
17
|
+
*/
|
|
18
|
+
/** The only tool detail allowed past this boundary (contract C3). */
|
|
19
|
+
const SUMMARY_MAX_CHARS = 120;
|
|
20
|
+
/**
|
|
21
|
+
* 83–88% of stream lines are `thinking_tokens` counters (A0). One Heartbeat
|
|
22
|
+
* per second is the cap that keeps `events.jsonl` from being ~85% counter
|
|
23
|
+
* spam while still proving the run is alive.
|
|
24
|
+
*/
|
|
25
|
+
const HEARTBEAT_MIN_INTERVAL_MS = 1000;
|
|
26
|
+
/**
|
|
27
|
+
* A command counts as validation only when a runner starts one of its
|
|
28
|
+
* segments. The pattern used to match anywhere in the string, so
|
|
29
|
+
* `ls eslint.config.* vitest.config.*` was reported as a test run purely
|
|
30
|
+
* because a FILENAME contained "vitest" (seen live on 2026-09-20).
|
|
31
|
+
*/
|
|
32
|
+
const VALIDATION_SEGMENT = /^(npm|pnpm|yarn) (run )?(test|lint|typecheck)\b|^npx \s*(vitest|jest)\b|^(vitest|jest|pytest|tsc)\b|^go test\b|^cargo test\b|^python3? (-m (pytest|unittest)\b|\S*test\S*\.py)/;
|
|
33
|
+
/** Shell operators that separate one executed command from the next. */
|
|
34
|
+
const SEGMENT_SPLIT = /&&|\|\||;|\|/;
|
|
35
|
+
/** True when any segment of the command line starts with a test/lint runner. */
|
|
36
|
+
function isValidationCommand(command) {
|
|
37
|
+
return command
|
|
38
|
+
.split(SEGMENT_SPLIT)
|
|
39
|
+
.some((segment) => VALIDATION_SEGMENT.test(segment.trim()));
|
|
40
|
+
}
|
|
41
|
+
/** Tools whose ok completion changes a file, and therefore emits `FileChanged`. */
|
|
42
|
+
const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
|
|
43
|
+
/**
|
|
44
|
+
* Matched explicitly, never loosely: stderr also carries structured
|
|
45
|
+
* diagnostics such as `[claude-code:unrecognized_model] {"model":…}` on
|
|
46
|
+
* perfectly successful runs, and classifying those as retries would corrupt
|
|
47
|
+
* the retry counters the estimator feeds on (A0).
|
|
48
|
+
*/
|
|
49
|
+
const RETRY_LINE = /\bretr(?:y|ying|ies)\b|\brate[ _-]?limit|\boverload|too many requests|service unavailable|api error:?\s*\b(?:429|500|502|503|504|529)\b/i;
|
|
50
|
+
/**
|
|
51
|
+
* The adapter is I/O-free, so it has no secret list of its own to strip.
|
|
52
|
+
* Summaries still pass through `redact()` so the boundary exists here — when
|
|
53
|
+
* the spawn path (Phase D) can thread real secrets in, this is the one place
|
|
54
|
+
* they apply.
|
|
55
|
+
*/
|
|
56
|
+
const NO_SECRETS = [];
|
|
57
|
+
export function createAdapterState(cwd) {
|
|
58
|
+
return {
|
|
59
|
+
turn: 0,
|
|
60
|
+
pending: new Map(),
|
|
61
|
+
// 0 means "never beaten": any wall clock is already past it, so the first
|
|
62
|
+
// counter line of a run establishes liveness immediately.
|
|
63
|
+
lastHeartbeatMs: 0,
|
|
64
|
+
cwd,
|
|
65
|
+
sawResultSinceLastToolUse: false,
|
|
66
|
+
filesChanged: new Set(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Pure: same input + state produces the same events. Mutates `state` (that is
|
|
71
|
+
* its job — pending tools, turn counter, heartbeat throttle), but touches no
|
|
72
|
+
* clock, file or network besides the injected `now`.
|
|
73
|
+
*/
|
|
74
|
+
export function adaptClaudeMessage(raw, state, now) {
|
|
75
|
+
try {
|
|
76
|
+
return mapMessage(raw, state, now ?? Date.now);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// The mappers below already shape-check everything; this catch is for
|
|
80
|
+
// adapter bugs, so the "malformed line must not kill a run" property
|
|
81
|
+
// holds even when the bug is ours.
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
logger.debug(`claude-adapter: dropping message that failed to map: ${message}`);
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Wraps the pure mapper with NDJSON line buffering (partial chunks, `\r\n`,
|
|
89
|
+
* blank lines) and the stderr retry sink. One adapter instance per run: the
|
|
90
|
+
* buffered tail and the state it carries are per-stream.
|
|
91
|
+
*/
|
|
92
|
+
export function createStreamAdapter(cwd, deps) {
|
|
93
|
+
const state = createAdapterState(cwd);
|
|
94
|
+
const now = deps?.now;
|
|
95
|
+
let buffered = "";
|
|
96
|
+
const consumeLine = (line) => {
|
|
97
|
+
const trimmed = line.replace(/\r$/, "").trim();
|
|
98
|
+
if (trimmed === "") {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
return adaptClaudeMessage(JSON.parse(trimmed), state, now);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
logger.debug("claude-adapter: stdout line is not valid JSON");
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
onStdoutLine(line) {
|
|
111
|
+
return consumeLine(line);
|
|
112
|
+
},
|
|
113
|
+
// Only `\n`-terminated lines are consumed — the `\r` of a `\r\n` pair
|
|
114
|
+
// rides along in the line and is stripped — so a JSON object split
|
|
115
|
+
// across two chunks still parses exactly once, when complete.
|
|
116
|
+
onStdoutChunk(chunk) {
|
|
117
|
+
buffered += chunk;
|
|
118
|
+
const events = [];
|
|
119
|
+
let newlineAt = buffered.indexOf("\n");
|
|
120
|
+
while (newlineAt >= 0) {
|
|
121
|
+
const line = buffered.slice(0, newlineAt);
|
|
122
|
+
buffered = buffered.slice(newlineAt + 1);
|
|
123
|
+
events.push(...consumeLine(line));
|
|
124
|
+
newlineAt = buffered.indexOf("\n");
|
|
125
|
+
}
|
|
126
|
+
return events;
|
|
127
|
+
},
|
|
128
|
+
onStderrLine(line) {
|
|
129
|
+
return mapStderrLine(line);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function mapMessage(raw, state, now) {
|
|
134
|
+
if (!isRecord(raw)) {
|
|
135
|
+
logger.debug("claude-adapter: ignoring non-object stream line");
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
switch (raw.type) {
|
|
139
|
+
case "system":
|
|
140
|
+
return mapSystemMessage(raw, state, now);
|
|
141
|
+
case "assistant":
|
|
142
|
+
return mapAssistantMessage(raw, state, now);
|
|
143
|
+
case "user":
|
|
144
|
+
return mapUserMessage(raw, state, now);
|
|
145
|
+
case "result":
|
|
146
|
+
return mapResultMessage(raw, state);
|
|
147
|
+
default:
|
|
148
|
+
logger.debug(`claude-adapter: unrecognized stream message type ${JSON.stringify(raw.type)}`);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function mapSystemMessage(raw, state, now) {
|
|
153
|
+
switch (raw.subtype) {
|
|
154
|
+
case "init":
|
|
155
|
+
return mapInit(raw, state);
|
|
156
|
+
case "thinking_tokens":
|
|
157
|
+
return mapThinkingTokens(state, now);
|
|
158
|
+
case "permission_denied":
|
|
159
|
+
return mapPermissionDenied(raw, state);
|
|
160
|
+
// User hooks fire inside the stream (A0); they are neither tool activity
|
|
161
|
+
// nor errors, so they are recognized and dropped without a log line.
|
|
162
|
+
case "hook_started":
|
|
163
|
+
case "hook_response":
|
|
164
|
+
return [];
|
|
165
|
+
default:
|
|
166
|
+
logger.debug(`claude-adapter: unrecognized system subtype ${JSON.stringify(raw.subtype)}`);
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function mapInit(raw, state) {
|
|
171
|
+
const sessionId = asString(raw.session_id);
|
|
172
|
+
const model = asString(raw.model);
|
|
173
|
+
if (sessionId === undefined || model === undefined) {
|
|
174
|
+
logger.debug("claude-adapter: system/init missing session_id or model");
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
state.sessionId = sessionId;
|
|
178
|
+
const tools = Array.isArray(raw.tools)
|
|
179
|
+
? raw.tools.filter((tool) => typeof tool === "string")
|
|
180
|
+
: [];
|
|
181
|
+
return [{ type: "AgentInitialized", sessionId, model, tools }];
|
|
182
|
+
}
|
|
183
|
+
function mapThinkingTokens(state, now) {
|
|
184
|
+
const at = now();
|
|
185
|
+
if (at - state.lastHeartbeatMs < HEARTBEAT_MIN_INTERVAL_MS) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
state.lastHeartbeatMs = at;
|
|
189
|
+
return [{ type: "Heartbeat", state: "working", turn: state.turn }];
|
|
190
|
+
}
|
|
191
|
+
function mapPermissionDenied(raw, state) {
|
|
192
|
+
const toolUseId = asString(raw.tool_use_id);
|
|
193
|
+
const tool = asString(raw.tool_name);
|
|
194
|
+
const reason = asString(raw.decision_reason) ?? asString(raw.message);
|
|
195
|
+
if (toolUseId === undefined || tool === undefined || reason === undefined) {
|
|
196
|
+
logger.debug("claude-adapter: system/permission_denied missing tool_use_id, tool_name or reason");
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
// The denied tool never ran: drop the pending entry so the error
|
|
200
|
+
// tool_result that follows maps to nothing instead of a bogus ToolCompleted.
|
|
201
|
+
state.pending.delete(toolUseId);
|
|
202
|
+
return [{ type: "ToolDenied", turn: state.turn, toolUseId, tool, reason: sanitize(reason) }];
|
|
203
|
+
}
|
|
204
|
+
function mapAssistantMessage(raw, state, now) {
|
|
205
|
+
const message = raw.message;
|
|
206
|
+
if (!isRecord(message) || !Array.isArray(message.content)) {
|
|
207
|
+
logger.debug("claude-adapter: assistant message without a content array");
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
const events = [];
|
|
211
|
+
for (const block of message.content) {
|
|
212
|
+
if (!isRecord(block)) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
switch (block.type) {
|
|
216
|
+
// Raw model reasoning: dropped at the boundary, never summarized,
|
|
217
|
+
// never persisted (contract C3, A0).
|
|
218
|
+
case "thinking":
|
|
219
|
+
break;
|
|
220
|
+
// Final answer text is the spawn path's business (stdout, contract C1);
|
|
221
|
+
// no event ever carries an LLM response body.
|
|
222
|
+
case "text":
|
|
223
|
+
break;
|
|
224
|
+
case "tool_use":
|
|
225
|
+
events.push(...mapToolUse(block, state, now));
|
|
226
|
+
break;
|
|
227
|
+
default:
|
|
228
|
+
logger.debug(`claude-adapter: unrecognized assistant content block ${JSON.stringify(block.type)}`);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return events;
|
|
233
|
+
}
|
|
234
|
+
function mapToolUse(block, state, now) {
|
|
235
|
+
const toolUseId = asString(block.id);
|
|
236
|
+
const tool = asString(block.name);
|
|
237
|
+
if (toolUseId === undefined || tool === undefined) {
|
|
238
|
+
logger.debug("claude-adapter: tool_use block without id or name");
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
const input = isRecord(block.input) ? block.input : {};
|
|
242
|
+
const command = asString(input.command);
|
|
243
|
+
const summary = summarizeToolUse(tool, input, state.cwd);
|
|
244
|
+
const isValidation = tool === "Bash" && command !== undefined && isValidationCommand(command);
|
|
245
|
+
const events = [];
|
|
246
|
+
// Turn derivation (A0): one turn per tool_result → next tool_use cycle.
|
|
247
|
+
// A TurnStarted per assistant message would roughly double every count,
|
|
248
|
+
// because the stream emits thinking / tool_use / text as separate messages.
|
|
249
|
+
if (state.turn === 0 || state.sawResultSinceLastToolUse) {
|
|
250
|
+
state.turn += 1;
|
|
251
|
+
state.sawResultSinceLastToolUse = false;
|
|
252
|
+
events.push({ type: "TurnStarted", turn: state.turn });
|
|
253
|
+
}
|
|
254
|
+
if (isValidation) {
|
|
255
|
+
events.push({ type: "ValidationStarted", turn: state.turn, command: summary });
|
|
256
|
+
}
|
|
257
|
+
events.push({ type: "ToolStarted", turn: state.turn, toolUseId, tool, summary });
|
|
258
|
+
state.pending.set(toolUseId, { tool, summary, startedMs: now(), isValidation, turn: state.turn });
|
|
259
|
+
return events;
|
|
260
|
+
}
|
|
261
|
+
function mapUserMessage(raw, state, now) {
|
|
262
|
+
const message = raw.message;
|
|
263
|
+
if (!isRecord(message) || !Array.isArray(message.content)) {
|
|
264
|
+
logger.debug("claude-adapter: user message without a content array");
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
const events = [];
|
|
268
|
+
for (const block of message.content) {
|
|
269
|
+
if (!isRecord(block) || block.type !== "tool_result") {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// Any tool_result closes a turn's tool cycle — even one with no matching
|
|
273
|
+
// pending entry — so the next tool_use still opens a new turn.
|
|
274
|
+
state.sawResultSinceLastToolUse = true;
|
|
275
|
+
events.push(...mapToolResult(block, state, now));
|
|
276
|
+
}
|
|
277
|
+
return events;
|
|
278
|
+
}
|
|
279
|
+
function mapToolResult(block, state, now) {
|
|
280
|
+
const toolUseId = asString(block.tool_use_id);
|
|
281
|
+
if (toolUseId === undefined) {
|
|
282
|
+
logger.debug("claude-adapter: tool_result block without tool_use_id");
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
const pending = state.pending.get(toolUseId);
|
|
286
|
+
if (pending === undefined) {
|
|
287
|
+
// Expected right after a permission_denied dropped the entry, or on
|
|
288
|
+
// schema drift; either way there is nothing truthful to report.
|
|
289
|
+
logger.debug(`claude-adapter: tool_result for unknown tool_use_id ${toolUseId}`);
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
state.pending.delete(toolUseId);
|
|
293
|
+
const ok = block.is_error !== true;
|
|
294
|
+
const durationMs = Math.max(0, now() - pending.startedMs);
|
|
295
|
+
const events = [
|
|
296
|
+
{ type: "ToolCompleted", turn: pending.turn, toolUseId, tool: pending.tool, ok, durationMs },
|
|
297
|
+
];
|
|
298
|
+
if (pending.isValidation) {
|
|
299
|
+
events.push({ type: "ValidationCompleted", turn: pending.turn, command: pending.summary, ok, durationMs });
|
|
300
|
+
}
|
|
301
|
+
if (ok && FILE_TOOLS.has(pending.tool)) {
|
|
302
|
+
// The summary for these tools *is* the repo-relative path, which is why
|
|
303
|
+
// PendingTool needs no copy of the raw input (C3).
|
|
304
|
+
events.push({
|
|
305
|
+
type: "FileChanged",
|
|
306
|
+
turn: pending.turn,
|
|
307
|
+
path: pending.summary,
|
|
308
|
+
op: pending.tool === "Write" ? "write" : "edit",
|
|
309
|
+
});
|
|
310
|
+
state.filesChanged.add(pending.summary);
|
|
311
|
+
}
|
|
312
|
+
return events;
|
|
313
|
+
}
|
|
314
|
+
function mapResultMessage(raw, state) {
|
|
315
|
+
if (raw.subtype === "success") {
|
|
316
|
+
const turns = asFiniteNumber(raw.num_turns);
|
|
317
|
+
const durationMs = asFiniteNumber(raw.duration_ms);
|
|
318
|
+
if (turns === undefined || durationMs === undefined) {
|
|
319
|
+
logger.debug("claude-adapter: success result missing num_turns or duration_ms");
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
const usage = isRecord(raw.usage) ? raw.usage : {};
|
|
323
|
+
// Token counts are real (they come from the API response).
|
|
324
|
+
// `total_cost_usd` / `modelUsage[].costUSD` are deliberately never read:
|
|
325
|
+
// Claude Code computes them with Anthropic's price table for a model it
|
|
326
|
+
// does not know, so for this stack they are fiction (A0). GLM bills in
|
|
327
|
+
// plan credits; the quota delta stays the only cost truth.
|
|
328
|
+
return [
|
|
329
|
+
{
|
|
330
|
+
type: "RunCompleted",
|
|
331
|
+
turns,
|
|
332
|
+
durationMs,
|
|
333
|
+
filesChanged: state.filesChanged.size,
|
|
334
|
+
tokensIn: asFiniteNumber(usage.input_tokens) ?? 0,
|
|
335
|
+
tokensOut: asFiniteNumber(usage.output_tokens) ?? 0,
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
return [
|
|
340
|
+
{
|
|
341
|
+
type: "RunFailed",
|
|
342
|
+
reason: sanitize(asString(raw.terminal_reason) ?? asString(raw.subtype) ?? "unknown"),
|
|
343
|
+
exitCode: 1,
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
function mapStderrLine(line) {
|
|
348
|
+
const trimmed = line.trim();
|
|
349
|
+
if (trimmed === "" || !RETRY_LINE.test(trimmed)) {
|
|
350
|
+
// Everything else is a passthrough diagnostic for the spawn path to
|
|
351
|
+
// forward, not an event.
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
const attempt = /\battempt (\d+)\b/i.exec(trimmed);
|
|
355
|
+
return attempt === null
|
|
356
|
+
? [{ type: "ApiRetry", reason: sanitize(trimmed) }]
|
|
357
|
+
: [{ type: "ApiRetry", attempt: Number(attempt[1]), reason: sanitize(trimmed) }];
|
|
358
|
+
}
|
|
359
|
+
function summarizeToolUse(tool, input, cwd) {
|
|
360
|
+
let text;
|
|
361
|
+
switch (tool) {
|
|
362
|
+
case "Read":
|
|
363
|
+
case "Edit":
|
|
364
|
+
case "Write":
|
|
365
|
+
case "MultiEdit": {
|
|
366
|
+
const filePath = asString(input.file_path);
|
|
367
|
+
text = filePath === undefined ? tool : toRepoRelative(filePath, cwd);
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
case "Bash": {
|
|
371
|
+
const command = asString(input.command);
|
|
372
|
+
text = command === undefined ? tool : firstLine(command);
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case "Grep":
|
|
376
|
+
case "Glob": {
|
|
377
|
+
const pattern = asString(input.pattern);
|
|
378
|
+
text = pattern === undefined ? tool : pattern;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
default:
|
|
382
|
+
text = tool;
|
|
383
|
+
}
|
|
384
|
+
return sanitize(text);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Repo-relative when the path is under the cwd, untouched otherwise. A plain
|
|
388
|
+
* prefix check rather than `path.relative`, so the POSIX paths in the
|
|
389
|
+
* captured fixtures and Windows paths on a live run behave identically and
|
|
390
|
+
* the adapter stays platform-free.
|
|
391
|
+
*/
|
|
392
|
+
function toRepoRelative(filePath, cwd) {
|
|
393
|
+
for (const sep of ["/", "\\"]) {
|
|
394
|
+
const prefix = cwd.endsWith(sep) ? cwd : cwd + sep;
|
|
395
|
+
if (filePath.startsWith(prefix)) {
|
|
396
|
+
return filePath.slice(prefix.length);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return filePath;
|
|
400
|
+
}
|
|
401
|
+
function firstLine(command) {
|
|
402
|
+
return command.split(/\r?\n/)[0];
|
|
403
|
+
}
|
|
404
|
+
/** Every user-facing string from the adapter is redacted and short (C3). */
|
|
405
|
+
function sanitize(text) {
|
|
406
|
+
return redact(text, NO_SECRETS).slice(0, SUMMARY_MAX_CHARS);
|
|
407
|
+
}
|
|
408
|
+
function isRecord(value) {
|
|
409
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
410
|
+
}
|
|
411
|
+
function asString(value) {
|
|
412
|
+
return typeof value === "string" ? value : undefined;
|
|
413
|
+
}
|
|
414
|
+
function asFiniteNumber(value) {
|
|
415
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
416
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical v2 event model (specs/v2-architecture.md, Phase A).
|
|
3
|
+
*
|
|
4
|
+
* One discriminated union feeds every v2 consumer — run store, stderr
|
|
5
|
+
* renderer, checkpoint builder, drain controller — so these shapes are the
|
|
6
|
+
* contract the whole observability stack is built on. Types only: there is
|
|
7
|
+
* deliberately no runtime code in this file.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { logger } from "../core/logging.js";
|
|
4
|
+
import { gitTopLevel, runGit } from "../core/git.js";
|
|
5
|
+
import { atomicWriteFile } from "../project/atomic-write.js";
|
|
6
|
+
import { CHECKPOINT_FILE_NAME, writeCheckpoint } from "../runs/checkpoint.js";
|
|
7
|
+
/** Where the bundle lands: `<runDir>/handoff/` (doc §17). */
|
|
8
|
+
export const HANDOFF_DIR_NAME = "handoff";
|
|
9
|
+
/** The one provider v2 knows (H2/D5), hardcoded into the H5 handoff block. */
|
|
10
|
+
const ZAI_PROVIDER = "zai.zcode";
|
|
11
|
+
/** H5: v3 §18's cross-provider handoff targets the orchestrator, by name. */
|
|
12
|
+
const ORCHESTRATOR_PROVIDER = "anthropic.claude-code";
|
|
13
|
+
/**
|
|
14
|
+
* Writes the handoff bundle (doc §17): `checkpoint.json`, `diff.patch`,
|
|
15
|
+
* `handoff.md`, `handoff.json` under `<runDir>/handoff/`.
|
|
16
|
+
*
|
|
17
|
+
* **Never fails the run.** Returns null only when the bundle directory itself
|
|
18
|
+
* cannot be created; every git call and every file write is individually
|
|
19
|
+
* guarded, so one degraded section (no diff, no branch, a lost file) becomes a
|
|
20
|
+
* debug log while the rest of the bundle still reaches disk. The caller prints
|
|
21
|
+
* the bundle path and carries on regardless — the work on disk is the thing
|
|
22
|
+
* being rescued here, and it is already safe.
|
|
23
|
+
*
|
|
24
|
+
* `diff.patch` is plain `git diff` — tracked changes only, never `git add`
|
|
25
|
+
* (this repo's no-automatic-git rule is exactly why untracked files get their
|
|
26
|
+
* own heading in `handoff.md` instead of being staged into the diff). Plain
|
|
27
|
+
* `git diff` rather than `git diff HEAD` deliberately: the router never stages
|
|
28
|
+
* anything, and on an unborn HEAD `git diff` still exits 0 where `HEAD` would
|
|
29
|
+
* fail and needlessly drop the diff section.
|
|
30
|
+
*
|
|
31
|
+
* C3: every task-derived string here (title, completed lines, validation
|
|
32
|
+
* commands, file paths) comes from the checkpoint, whose fields were already
|
|
33
|
+
* redacted and capped upstream. No prompt body, tool result or LLM response is
|
|
34
|
+
* ever read, and nothing from the environment is written.
|
|
35
|
+
*/
|
|
36
|
+
export async function writeHandoffBundle(input) {
|
|
37
|
+
const dir = path.join(input.runDir, HANDOFF_DIR_NAME);
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logger.debug(`handoff bundle: creating ${dir} failed: ${errorMessage(error)}`);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const handoffMd = path.join(dir, "handoff.md");
|
|
46
|
+
const handoffJson = path.join(dir, "handoff.json");
|
|
47
|
+
const checkpointJson = path.join(dir, CHECKPOINT_FILE_NAME);
|
|
48
|
+
const diffFile = path.join(dir, "diff.patch");
|
|
49
|
+
const git = input.runGit ?? runGit;
|
|
50
|
+
const workspace = await workspaceInfo(input.cwd, git);
|
|
51
|
+
const diff = workspace.isRepo ? await trackedDiff(input.cwd, git) : null;
|
|
52
|
+
const diffPatch = diff !== null && guardedWrite(diffFile, diff) ? diffFile : null;
|
|
53
|
+
// Reuses the never-throwing writer so the bundle's copy is byte-identical to
|
|
54
|
+
// the one the run directory itself may already hold.
|
|
55
|
+
writeCheckpoint(dir, input.checkpoint);
|
|
56
|
+
guardedWrite(handoffMd, renderMarkdown(input, workspace, diffPatch !== null));
|
|
57
|
+
guardedWrite(handoffJson, JSON.stringify({
|
|
58
|
+
// Doc §18's shape, verbatim in key order...
|
|
59
|
+
status: "handoff_required",
|
|
60
|
+
run_id: input.runId,
|
|
61
|
+
reason: input.reason,
|
|
62
|
+
completed: input.checkpoint.completed,
|
|
63
|
+
pending: input.checkpoint.pending,
|
|
64
|
+
handoff_path: handoffMd,
|
|
65
|
+
// ...then hedge H5: v3 §17's from/to and v4 §20's workspace block, so
|
|
66
|
+
// a v2 bundle stays readable by v3/v4 without a migration pass.
|
|
67
|
+
from: { provider: ZAI_PROVIDER, role: input.role },
|
|
68
|
+
to: { provider: ORCHESTRATOR_PROVIDER, role: "orchestrator" },
|
|
69
|
+
workspace: { repo: workspace.repo, worktree: workspace.worktree, branch: workspace.branch },
|
|
70
|
+
bundle: { checkpoint: checkpointJson, diff: diffPatch, markdown: handoffMd },
|
|
71
|
+
}, null, 2) + "\n");
|
|
72
|
+
return { dir, handoffMd, handoffJson, checkpointJson, diffPatch };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Everything C3 allows a human to read: the title (the checkpoint's first
|
|
76
|
+
* pending entry), the run id and reason, done/remaining/files/validation from
|
|
77
|
+
* the checkpoint, and the git facts. Untracked files get their OWN heading
|
|
78
|
+
* because they are invisible to `diff.patch` — a reader who assumes the patch
|
|
79
|
+
* is the whole story silently loses them.
|
|
80
|
+
*/
|
|
81
|
+
function renderMarkdown(input, workspace, diffWritten) {
|
|
82
|
+
const checkpoint = input.checkpoint;
|
|
83
|
+
const title = checkpoint.pending.length > 0 ? checkpoint.pending[0] : "continue the task";
|
|
84
|
+
const workspaceLine = workspace.isRepo
|
|
85
|
+
? `${workspace.repo ?? "?"} on ${workspace.branch ?? "(detached HEAD)"} — ${workspace.worktree ?? input.cwd}`
|
|
86
|
+
: "not a git repository";
|
|
87
|
+
const lines = [
|
|
88
|
+
`# Handoff: ${title}`,
|
|
89
|
+
"",
|
|
90
|
+
`- Run: ${input.runId}`,
|
|
91
|
+
`- Reason: ${input.reason}`,
|
|
92
|
+
`- Role: ${input.role} (model ${input.model})`,
|
|
93
|
+
`- Workspace: ${workspaceLine}`,
|
|
94
|
+
"",
|
|
95
|
+
"## Done",
|
|
96
|
+
...bullets(checkpoint.completed, "(no completed turns recorded)"),
|
|
97
|
+
"",
|
|
98
|
+
"## Remaining",
|
|
99
|
+
...bullets(checkpoint.pending, "(nothing recorded — see the run's events)"),
|
|
100
|
+
"",
|
|
101
|
+
"## Files changed",
|
|
102
|
+
...bullets(checkpoint.filesChanged, "(none)"),
|
|
103
|
+
];
|
|
104
|
+
if (workspace.isRepo) {
|
|
105
|
+
// The heading is the warning: these files are NOT inside diff.patch.
|
|
106
|
+
lines.push("", "## Untracked files (not in diff.patch)", ...bullets(workspace.untracked, "(none)"));
|
|
107
|
+
}
|
|
108
|
+
lines.push("", "## Validation still owed", ...bullets(checkpoint.validationPending, "(none)"), "", "## Diff");
|
|
109
|
+
if (!workspace.isRepo) {
|
|
110
|
+
lines.push("Not a git repository — no `diff.patch` was written.");
|
|
111
|
+
}
|
|
112
|
+
else if (!diffWritten) {
|
|
113
|
+
lines.push("Tracked changes could not be captured — `diff.patch` was not written. Run `git diff` yourself.");
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
lines.push("Tracked changes are in `diff.patch`. Untracked files are the ones listed above, not in the patch.");
|
|
117
|
+
}
|
|
118
|
+
lines.push("", "## Next step", "", `Continue the task in the same worktree (\`${input.cwd}\`) — pick up from "Remaining" above instead of re-running the worker from scratch.`, "");
|
|
119
|
+
return lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
function bullets(items, empty) {
|
|
122
|
+
return items.length > 0 ? items.map((item) => `- ${item}`) : [`- ${empty}`];
|
|
123
|
+
}
|
|
124
|
+
/** One guard per git question, so a broken answer degrades alone. */
|
|
125
|
+
async function workspaceInfo(cwd, git) {
|
|
126
|
+
let topLevel;
|
|
127
|
+
try {
|
|
128
|
+
// gitTopLevel rejects (rather than resolving) when git itself is missing,
|
|
129
|
+
// so even repo detection is inside the guard.
|
|
130
|
+
topLevel = await gitTopLevel(cwd, git);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
logger.debug(`handoff bundle: repo detection in ${cwd} failed: ${errorMessage(error)}`);
|
|
134
|
+
topLevel = undefined;
|
|
135
|
+
}
|
|
136
|
+
if (topLevel === undefined) {
|
|
137
|
+
return { isRepo: false, repo: null, worktree: null, branch: null, untracked: [] };
|
|
138
|
+
}
|
|
139
|
+
const branch = await currentBranch(cwd, git);
|
|
140
|
+
const untracked = await untrackedFiles(cwd, git);
|
|
141
|
+
return { isRepo: true, repo: path.basename(topLevel), worktree: topLevel, branch, untracked };
|
|
142
|
+
}
|
|
143
|
+
async function currentBranch(cwd, git) {
|
|
144
|
+
try {
|
|
145
|
+
const result = await git(["branch", "--show-current"], cwd);
|
|
146
|
+
if (result.code !== 0) {
|
|
147
|
+
logger.debug(`handoff bundle: branch lookup in ${cwd} exited ${result.code}`);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
// Empty output is a detached HEAD — truthful as null, not as "".
|
|
151
|
+
const branch = result.stdout.trim();
|
|
152
|
+
return branch.length > 0 ? branch : null;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
logger.debug(`handoff bundle: branch lookup in ${cwd} failed: ${errorMessage(error)}`);
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function untrackedFiles(cwd, git) {
|
|
160
|
+
try {
|
|
161
|
+
const result = await git(["status", "--porcelain", "-z"], cwd);
|
|
162
|
+
if (result.code !== 0) {
|
|
163
|
+
logger.debug(`handoff bundle: status in ${cwd} exited ${result.code}`);
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
// -z: NUL-terminated, unquoted — spaces in filenames survive intact.
|
|
167
|
+
return result.stdout
|
|
168
|
+
.split("\0")
|
|
169
|
+
.filter((entry) => entry.startsWith("?? "))
|
|
170
|
+
.map((entry) => entry.slice(3));
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
logger.debug(`handoff bundle: status in ${cwd} failed: ${errorMessage(error)}`);
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function trackedDiff(cwd, git) {
|
|
178
|
+
try {
|
|
179
|
+
const result = await git(["diff"], cwd);
|
|
180
|
+
if (result.code !== 0) {
|
|
181
|
+
logger.debug(`handoff bundle: git diff in ${cwd} exited ${result.code}`);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return result.stdout;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
logger.debug(`handoff bundle: git diff in ${cwd} failed: ${errorMessage(error)}`);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function guardedWrite(file, content) {
|
|
192
|
+
try {
|
|
193
|
+
atomicWriteFile(file, content);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
logger.debug(`handoff bundle: writing ${file} failed: ${errorMessage(error)}`);
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function errorMessage(error) {
|
|
202
|
+
return error instanceof Error ? error.message : String(error);
|
|
203
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ExitCode } from "../core/errors.js";
|
|
2
|
+
import { logger } from "../core/logging.js";
|
|
3
|
+
/**
|
|
4
|
+
* Emit a handoff to the parent session and return its exit code (D2).
|
|
5
|
+
*
|
|
6
|
+
* **stdout carries the JSON and nothing else.** Contract C1 reserves stdout
|
|
7
|
+
* for the run's final answer, and a handed-off run has none — it did not
|
|
8
|
+
* finish. Printing a partial answer next to the JSON would give an
|
|
9
|
+
* orchestrator two things to parse and no way to tell which is authoritative,
|
|
10
|
+
* so the JSON *is* the output of a run that ends this way.
|
|
11
|
+
*
|
|
12
|
+
* 41 and 42 differ only in what already happened: 41 is a preflight refusal
|
|
13
|
+
* that spawned nothing, 42 a live run stopped at a safe boundary with its work
|
|
14
|
+
* preserved in a bundle. Neither is a crash, which is exactly what the
|
|
15
|
+
* orchestrator templates now say.
|
|
16
|
+
*/
|
|
17
|
+
export function writeHandoffResult(streams, result, humanSummary, exitCode = ExitCode.HandoffRequired) {
|
|
18
|
+
streams.stdout.write(JSON.stringify(result) + "\n");
|
|
19
|
+
try {
|
|
20
|
+
streams.stderr.write(humanSummary.join("\n") + "\n");
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
// The machine-readable half already landed; losing the prose must not
|
|
24
|
+
// change the exit code the parent reads.
|
|
25
|
+
logger.debug(`parent handoff: writing the human summary failed: ${errorMessage(error)}`);
|
|
26
|
+
}
|
|
27
|
+
return exitCode;
|
|
28
|
+
}
|
|
29
|
+
/** The `[Router]` block a human reads on stderr when a live run hands off (doc §14, §18). */
|
|
30
|
+
export function handoffSummaryLines(input) {
|
|
31
|
+
const lines = [
|
|
32
|
+
`[Router] handing the task back to the parent session (${input.reason}).`,
|
|
33
|
+
`[Router] run ${input.runId} stopped at a safe boundary; its work is on disk.`,
|
|
34
|
+
];
|
|
35
|
+
if (input.completed.length > 0) {
|
|
36
|
+
lines.push("[Router] done so far:", ...input.completed.map((entry) => `[Router] ${entry}`));
|
|
37
|
+
}
|
|
38
|
+
if (input.pending.length > 0) {
|
|
39
|
+
lines.push("[Router] still to do:", ...input.pending.map((entry) => `[Router] ${entry}`));
|
|
40
|
+
}
|
|
41
|
+
lines.push(input.bundlePath === null
|
|
42
|
+
? "[Router] no bundle was written (nothing had changed on disk yet)."
|
|
43
|
+
: `[Router] bundle: ${input.bundlePath}`, "[Router] continue in the SAME worktree; do not re-run the worker until quota resets.");
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
function errorMessage(error) {
|
|
47
|
+
return error instanceof Error ? error.message : String(error);
|
|
48
|
+
}
|