@parall/claude-agent 1.51.0 → 1.52.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.
@@ -0,0 +1,137 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { DispatchInputLifecycle, GatewayLogger, RuntimeInputState } from '@parall/agent-core';
3
+
4
+ export type ClaudeInputDelivery = {
5
+ deliveryKey: string;
6
+ commandUuid: string;
7
+ lifecycle?: DispatchInputLifecycle;
8
+ injected: boolean;
9
+ drained: boolean;
10
+ terminal?: 'completed' | 'failed' | 'settled';
11
+ reportedState?: RuntimeInputState;
12
+ resultFailed: boolean;
13
+ /**
14
+ * The turn settled as usage_limit: the lane-level deferred complete will
15
+ * re-deliver this input at retry_at without burning redrive budget. A
16
+ * failed-input report here would trigger the immediate failed-input
17
+ * redrive straight back into the still-choked LLM
18
+ * (agent-turn-outcome-design.md §6).
19
+ */
20
+ suppressFailReport?: boolean;
21
+ };
22
+
23
+ /**
24
+ * Per-process registry that maps Parall WorkItem batches to Claude stdin
25
+ * UUIDs. It owns lifecycle transition rules; process/stdout orchestration
26
+ * stays in ClaudeCodeAdapter.
27
+ */
28
+ export class ClaudeInputRegistry {
29
+ private readonly byKey = new Map<string, ClaudeInputDelivery>();
30
+ private readonly byCommand = new Map<string, ClaudeInputDelivery>();
31
+
32
+ getByKey(deliveryKey: string): ClaudeInputDelivery | undefined {
33
+ return this.byKey.get(deliveryKey);
34
+ }
35
+
36
+ getByCommand(commandUuid: string): ClaudeInputDelivery | undefined {
37
+ return this.byCommand.get(commandUuid);
38
+ }
39
+
40
+ values(): IterableIterator<ClaudeInputDelivery> {
41
+ return this.byKey.values();
42
+ }
43
+
44
+ hasPendingInjections(): boolean {
45
+ return [...this.byKey.values()].some((delivery) => delivery.injected && !delivery.drained);
46
+ }
47
+
48
+ register(
49
+ deliveryKey: string,
50
+ lifecycle: DispatchInputLifecycle | undefined,
51
+ injected: boolean,
52
+ ): ClaudeInputDelivery {
53
+ if (this.byKey.has(deliveryKey)) {
54
+ throw new Error(`duplicate Claude delivery key ${deliveryKey}`);
55
+ }
56
+ const delivery: ClaudeInputDelivery = {
57
+ deliveryKey,
58
+ commandUuid: randomUUID(),
59
+ lifecycle,
60
+ injected,
61
+ drained: !injected,
62
+ resultFailed: false,
63
+ };
64
+ this.byKey.set(deliveryKey, delivery);
65
+ this.byCommand.set(delivery.commandUuid, delivery);
66
+ return delivery;
67
+ }
68
+
69
+ remove(delivery: ClaudeInputDelivery): void {
70
+ if (this.byKey.get(delivery.deliveryKey) === delivery) {
71
+ this.byKey.delete(delivery.deliveryKey);
72
+ }
73
+ if (this.byCommand.get(delivery.commandUuid) === delivery) {
74
+ this.byCommand.delete(delivery.commandUuid);
75
+ }
76
+ }
77
+
78
+ async apply(
79
+ delivery: ClaudeInputDelivery,
80
+ state: 'queued' | 'started' | 'completed' | 'cancelled' | 'discarded',
81
+ ): Promise<void> {
82
+ if (delivery.terminal || state === 'queued') return;
83
+
84
+ if (state === 'started') {
85
+ if (delivery.reportedState) return;
86
+ await delivery.lifecycle?.update('started');
87
+ delivery.reportedState = 'started';
88
+ return;
89
+ }
90
+
91
+ if (state === 'cancelled' || state === 'discarded' || delivery.resultFailed) {
92
+ await this.fail(delivery);
93
+ return;
94
+ }
95
+
96
+ if (delivery.reportedState !== 'completed') {
97
+ await delivery.lifecycle?.update('completed');
98
+ delivery.reportedState = 'completed';
99
+ }
100
+ delivery.terminal = 'completed';
101
+ }
102
+
103
+ async fail(delivery: ClaudeInputDelivery): Promise<void> {
104
+ if (delivery.terminal) return;
105
+ if (delivery.suppressFailReport) {
106
+ delivery.terminal = 'settled';
107
+ return;
108
+ }
109
+ try {
110
+ if (delivery.reportedState !== 'failed') {
111
+ const result = await delivery.lifecycle?.update('failed');
112
+ delivery.reportedState = 'failed';
113
+ delivery.terminal = result?.retry === false ? 'settled' : 'failed';
114
+ }
115
+ } finally {
116
+ delivery.terminal ??= 'failed';
117
+ }
118
+ }
119
+
120
+ async failBestEffort(delivery: ClaudeInputDelivery, log?: GatewayLogger): Promise<void> {
121
+ try {
122
+ await this.fail(delivery);
123
+ } catch (err) {
124
+ log?.warn?.(
125
+ `failed to report Claude input ${delivery.commandUuid} as failed: ${String(err)}`,
126
+ );
127
+ }
128
+ }
129
+
130
+ async failAllBestEffort(log?: GatewayLogger): Promise<void> {
131
+ await Promise.all(
132
+ [...this.byKey.values()]
133
+ .filter((delivery) => !delivery.terminal)
134
+ .map((delivery) => this.failBestEffort(delivery, log)),
135
+ );
136
+ }
137
+ }
@@ -1,9 +1,44 @@
1
1
  import type { Readable } from 'node:stream';
2
2
  import type { RuntimeEvent } from '@parall/agent-core';
3
3
 
4
+ /**
5
+ * Structured snapshot of the `result` frame's LLM-layer discriminators and
6
+ * accounting — the raw material for turn-outcome classification
7
+ * (agent-turn-outcome-design.md §4.1). Every field is optional: older CLIs
8
+ * omit some, and classification degrades gracefully.
9
+ */
10
+ export type ClaudeResultMeta = {
11
+ subtype?: string;
12
+ terminalReason?: string;
13
+ apiErrorStatus?: number;
14
+ stopReason?: string;
15
+ /** The frame's `result` text — synthetic failure notices land here. */
16
+ resultText?: string;
17
+ isError: boolean;
18
+ numTurns?: number;
19
+ durationMs?: number;
20
+ durationApiMs?: number;
21
+ totalCostUsd?: number;
22
+ inputTokens?: number;
23
+ outputTokens?: number;
24
+ cacheReadTokens?: number;
25
+ cacheCreationTokens?: number;
26
+ /** First model key of `modelUsage` — the model the CLI actually used. */
27
+ model?: string;
28
+ };
29
+
4
30
  export type ClaudeParsedEvent =
5
31
  | RuntimeEvent
6
- | { type: 'session_id'; sessionId: string }
32
+ | { type: 'runtime_init'; sessionId?: string; capabilities: string[] }
33
+ | {
34
+ type: 'command_lifecycle';
35
+ commandUuid: string;
36
+ state: 'queued' | 'started' | 'completed' | 'cancelled' | 'discarded';
37
+ }
38
+ // Synthetic-notice channel: an `assistant` frame carrying an `error` field
39
+ // is not a model message — surfaced so the turn-outcome classifier can see
40
+ // the text without it entering the RuntimeEvent stream.
41
+ | { type: 'assistant_error'; message: string }
7
42
  // turn_end is emitted for every `result` frame (both `is_error: true` and
8
43
  // `is_error: false`); `isError` carries the frame's status so the consumer
9
44
  // can tell a clean turn boundary from a failed one without inspecting the
@@ -21,7 +56,17 @@ export type ClaudeParsedEvent =
21
56
  // CLI emits at startup when `--resume` (with or without `--fork-session`)
22
57
  // targets a dangling-tail transcript reports 0 — consumers use it to
23
58
  // discriminate that poison frame from a real turn boundary.
24
- | { type: 'turn_end'; sessionId?: string; isError: boolean; numTurns?: number };
59
+ //
60
+ // `resultMeta` carries the frame's full discriminator/usage snapshot for
61
+ // turn-outcome classification.
62
+ | {
63
+ type: 'turn_end';
64
+ sessionId?: string;
65
+ userMessageUuid?: string;
66
+ isError: boolean;
67
+ numTurns?: number;
68
+ resultMeta?: ClaudeResultMeta;
69
+ };
25
70
 
26
71
  type ToolUseMeta = {
27
72
  toolName: string;
@@ -121,14 +166,50 @@ export async function* parseClaudeStreamJson(
121
166
 
122
167
  if (eventRecord.type === 'system' && eventRecord.subtype === 'init') {
123
168
  const sessionId = asTrimmedString(eventRecord.session_id);
124
- if (sessionId) {
125
- yield { type: 'session_id', sessionId };
169
+ const capabilities = Array.isArray(eventRecord.capabilities)
170
+ ? eventRecord.capabilities
171
+ .map((capability) => asTrimmedString(capability))
172
+ .filter((capability): capability is string => Boolean(capability))
173
+ : [];
174
+ yield {
175
+ type: 'runtime_init',
176
+ ...(sessionId ? { sessionId } : {}),
177
+ capabilities,
178
+ };
179
+ continue;
180
+ }
181
+
182
+ if (eventRecord.type === 'command_lifecycle') {
183
+ const commandUuid = asTrimmedString(eventRecord.command_uuid);
184
+ const state = asTrimmedString(eventRecord.state);
185
+ if (
186
+ commandUuid &&
187
+ (state === 'queued' ||
188
+ state === 'started' ||
189
+ state === 'completed' ||
190
+ state === 'cancelled' ||
191
+ state === 'discarded')
192
+ ) {
193
+ yield { type: 'command_lifecycle', commandUuid, state };
126
194
  }
127
195
  continue;
128
196
  }
129
197
 
130
198
  if (eventRecord.type === 'assistant') {
131
- if (eventRecord.error) continue;
199
+ if (eventRecord.error) {
200
+ // Not a model message — surface the notice text for turn-outcome
201
+ // classification instead of silently dropping the frame.
202
+ const message =
203
+ asTrimmedString(eventRecord.error) ??
204
+ asTrimmedString((eventRecord.error as { message?: unknown })?.message);
205
+ if (message) yield { type: 'assistant_error', message };
206
+ continue;
207
+ }
208
+ // Verbose stream-json includes nested subagent frames. Their final
209
+ // tool_result returns through the root conversation; projecting their
210
+ // internal assistant/tool stream here would attribute background work
211
+ // to whichever root input currently owns stdout.
212
+ if (asTrimmedString(eventRecord.parent_tool_use_id)) continue;
132
213
 
133
214
  const message = eventRecord.message;
134
215
  const content =
@@ -179,6 +260,7 @@ export async function* parseClaudeStreamJson(
179
260
  }
180
261
 
181
262
  if (eventRecord.type === 'user') {
263
+ if (asTrimmedString(eventRecord.parent_tool_use_id)) continue;
182
264
  const message = eventRecord.message;
183
265
  const content =
184
266
  message && typeof message === 'object'
@@ -226,12 +308,62 @@ export async function* parseClaudeStreamJson(
226
308
  // the map grow unbounded or mislabel durations on id collisions.
227
309
  toolUses.clear();
228
310
  const numTurns = asFiniteNumber(eventRecord.num_turns);
311
+ const userMessageUuid =
312
+ asTrimmedString(eventRecord.user_message_uuid) || asTrimmedString(eventRecord.command_uuid);
229
313
  yield {
230
314
  type: 'turn_end',
231
315
  sessionId: asTrimmedString(eventRecord.session_id),
316
+ ...(userMessageUuid ? { userMessageUuid } : {}),
232
317
  isError,
233
318
  ...(numTurns !== undefined ? { numTurns } : {}),
319
+ resultMeta: extractResultMeta(eventRecord, isError, numTurns),
234
320
  };
235
321
  }
236
322
  }
237
323
  }
324
+
325
+ /** Snapshot the result frame's discriminator + usage fields (all optional). */
326
+ function extractResultMeta(
327
+ frame: Record<string, unknown>,
328
+ isError: boolean,
329
+ numTurns: number | undefined,
330
+ ): ClaudeResultMeta {
331
+ const usage =
332
+ frame.usage && typeof frame.usage === 'object'
333
+ ? (frame.usage as Record<string, unknown>)
334
+ : undefined;
335
+ const modelUsage =
336
+ frame.modelUsage && typeof frame.modelUsage === 'object'
337
+ ? Object.keys(frame.modelUsage as Record<string, unknown>)
338
+ : [];
339
+ const meta: ClaudeResultMeta = { isError };
340
+ if (numTurns !== undefined) meta.numTurns = numTurns;
341
+ const subtype = asTrimmedString(frame.subtype);
342
+ if (subtype) meta.subtype = subtype;
343
+ const terminalReason = asTrimmedString(frame.terminal_reason);
344
+ if (terminalReason) meta.terminalReason = terminalReason;
345
+ const apiErrorStatus = asFiniteNumber(frame.api_error_status);
346
+ if (apiErrorStatus !== undefined) meta.apiErrorStatus = apiErrorStatus;
347
+ const stopReason = asTrimmedString(frame.stop_reason);
348
+ if (stopReason) meta.stopReason = stopReason;
349
+ const resultText = asTrimmedString(frame.result);
350
+ if (resultText) meta.resultText = resultText;
351
+ const durationMs = asFiniteNumber(frame.duration_ms);
352
+ if (durationMs !== undefined) meta.durationMs = durationMs;
353
+ const durationApiMs = asFiniteNumber(frame.duration_api_ms);
354
+ if (durationApiMs !== undefined) meta.durationApiMs = durationApiMs;
355
+ const totalCostUsd = asFiniteNumber(frame.total_cost_usd);
356
+ if (totalCostUsd !== undefined) meta.totalCostUsd = totalCostUsd;
357
+ if (usage) {
358
+ const inputTokens = asFiniteNumber(usage.input_tokens);
359
+ if (inputTokens !== undefined) meta.inputTokens = inputTokens;
360
+ const outputTokens = asFiniteNumber(usage.output_tokens);
361
+ if (outputTokens !== undefined) meta.outputTokens = outputTokens;
362
+ const cacheReadTokens = asFiniteNumber(usage.cache_read_input_tokens);
363
+ if (cacheReadTokens !== undefined) meta.cacheReadTokens = cacheReadTokens;
364
+ const cacheCreationTokens = asFiniteNumber(usage.cache_creation_input_tokens);
365
+ if (cacheCreationTokens !== undefined) meta.cacheCreationTokens = cacheCreationTokens;
366
+ }
367
+ if (modelUsage.length > 0) meta.model = modelUsage[0];
368
+ return meta;
369
+ }
@@ -0,0 +1,177 @@
1
+ import type { TurnOutcomeEvent, TurnUsage } from '@parall/agent-core';
2
+ import type { ClaudeResultMeta } from './output-parser.js';
3
+
4
+ /**
5
+ * Claude turn-outcome classifier (agent-turn-outcome-design.md §4.1).
6
+ *
7
+ * Detection order is a hard rule: structured fields first
8
+ * (api_error_status / terminal_reason / is_error), behavioral heuristics
9
+ * second (zero-usage synthetic turns), message regex last — and regex only
10
+ * ever refines a classification, so a CLI wording change degrades to the
11
+ * generic `api_error`, never to a wrong `ok` or a wrong deferral.
12
+ * Classification uncertainty must never defer: deferring a real message is
13
+ * costlier than losing one automatic recovery.
14
+ */
15
+
16
+ const LIMIT_TEXT = /you'?ve (hit|reached) your .*limit|usage limit reached|weekly limit/i;
17
+ const AUTH_TEXT =
18
+ /not logged in|please run \/login|authentication_error|invalid api key|oauth token (has )?expired|\[action required\]/i;
19
+ const CONTEXT_TEXT = /prompt is too long|context (window|length) exceeded|request too large/i;
20
+
21
+ const DETAIL_MAX = 500;
22
+
23
+ /**
24
+ * Parse the reset time out of a limit notice like
25
+ * `You've hit your session limit · resets 1:20am (Asia/Shanghai)` /
26
+ * `… resets at 11pm (UTC)`. Returns the NEXT occurrence of that wall-clock
27
+ * time in the given zone as an ISO string, or undefined when the wording
28
+ * doesn't match — the server then applies its default deferral window.
29
+ */
30
+ function minutesOfDayInZone(fmt: Intl.DateTimeFormat, at: Date): number {
31
+ const parts = Object.fromEntries(
32
+ fmt
33
+ .formatToParts(at)
34
+ .filter((p) => p.type !== 'literal')
35
+ .map((p) => [p.type, Number(p.value)]),
36
+ ) as Record<string, number>;
37
+ // "24" can appear for midnight under hour12:false on some ICU versions.
38
+ const hour = parts.hour === 24 ? 0 : parts.hour;
39
+ return hour * 60 + parts.minute;
40
+ }
41
+
42
+ export function parseClaudeResetTime(text: string, now: Date): string | undefined {
43
+ const m = /resets\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(([^)]+)\)/i.exec(text);
44
+ if (!m) return undefined;
45
+ let hour = Number(m[1]);
46
+ const minute = m[2] ? Number(m[2]) : 0;
47
+ const meridiem = m[3]?.toLowerCase();
48
+ const timeZone = m[4].trim();
49
+ if (!Number.isFinite(hour) || hour > 23 || minute > 59) return undefined;
50
+ if (meridiem === 'pm' && hour < 12) hour += 12;
51
+ if (meridiem === 'am' && hour === 12) hour = 0;
52
+
53
+ // Intl throws on unknown zone names — treat that as no parse.
54
+ let fmt: Intl.DateTimeFormat;
55
+ let nowMinutesOfDay: number;
56
+ try {
57
+ fmt = new Intl.DateTimeFormat('en-US', {
58
+ timeZone,
59
+ hour12: false,
60
+ hour: '2-digit',
61
+ minute: '2-digit',
62
+ });
63
+ nowMinutesOfDay = minutesOfDayInZone(fmt, now);
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ const targetMinutesOfDay = hour * 60 + minute;
68
+ let deltaMinutes = targetMinutesOfDay - nowMinutesOfDay;
69
+ if (deltaMinutes <= 0) deltaMinutes += 24 * 60;
70
+ // Anchor on the real instant (drop seconds so the result lands exactly on
71
+ // the notice's minute), advance by the wall-clock delta, then CORRECT for
72
+ // any DST transition inside the interval: wall-clock minutes ≠ elapsed UTC
73
+ // minutes across a shift, so re-read the candidate's wall-clock in the
74
+ // zone and nudge by the difference (two rounds absorb a single transition;
75
+ // a spring-forward nonexistent time settles within an hour, which the
76
+ // server-side clamp tolerates).
77
+ const anchored = now.getTime() - (now.getSeconds() * 1000 + now.getMilliseconds());
78
+ let target = anchored + deltaMinutes * 60_000;
79
+ for (let i = 0; i < 2; i++) {
80
+ let diff = targetMinutesOfDay - minutesOfDayInZone(fmt, new Date(target));
81
+ if (diff > 720) diff -= 1440;
82
+ if (diff < -720) diff += 1440;
83
+ if (diff === 0) break;
84
+ target += diff * 60_000;
85
+ }
86
+ if (target <= now.getTime()) target += 24 * 60 * 60_000;
87
+ return new Date(target).toISOString();
88
+ }
89
+
90
+ function usageOf(meta: ClaudeResultMeta): TurnUsage | undefined {
91
+ const usage: TurnUsage = {};
92
+ if (meta.inputTokens !== undefined) usage.inputTokens = meta.inputTokens;
93
+ if (meta.outputTokens !== undefined) usage.outputTokens = meta.outputTokens;
94
+ if (meta.cacheReadTokens !== undefined) usage.cacheReadTokens = meta.cacheReadTokens;
95
+ if (meta.cacheCreationTokens !== undefined) usage.cacheCreationTokens = meta.cacheCreationTokens;
96
+ if (meta.totalCostUsd !== undefined) usage.costUsd = meta.totalCostUsd;
97
+ if (meta.durationMs !== undefined) usage.durationMs = meta.durationMs;
98
+ if (meta.durationApiMs !== undefined) usage.durationApiMs = meta.durationApiMs;
99
+ return Object.keys(usage).length > 0 ? usage : undefined;
100
+ }
101
+
102
+ /**
103
+ * Classify one finished Claude turn from its result-frame snapshot plus any
104
+ * synthetic notice texts observed during the turn (assistant_error frames,
105
+ * zero-usage assistant text like the session-limit notice).
106
+ */
107
+ export function classifyClaudeTurn(
108
+ meta: ClaudeResultMeta | undefined,
109
+ noticeTexts: string[],
110
+ now: Date = new Date(),
111
+ ): TurnOutcomeEvent {
112
+ if (!meta) {
113
+ // No result frame reached us (subprocess died mid-turn) — the dispatch
114
+ // layer's own error path reports the crash; classify what we know.
115
+ return { type: 'turn_outcome', outcome: 'runtime_crash' };
116
+ }
117
+ const usage = usageOf(meta);
118
+ const evidence = [meta.resultText, ...noticeTexts].filter(Boolean).join('\n');
119
+ const base = {
120
+ type: 'turn_outcome' as const,
121
+ ...(usage ? { usage } : {}),
122
+ ...(meta.model ? { model: meta.model } : {}),
123
+ raw: {
124
+ ...(meta.subtype ? { subtype: meta.subtype } : {}),
125
+ ...(meta.terminalReason ? { terminal_reason: meta.terminalReason } : {}),
126
+ ...(meta.apiErrorStatus !== undefined ? { api_error_status: meta.apiErrorStatus } : {}),
127
+ ...(meta.stopReason ? { stop_reason: meta.stopReason } : {}),
128
+ },
129
+ };
130
+ const withDetail = (
131
+ outcome: TurnOutcomeEvent['outcome'],
132
+ retryAt?: string,
133
+ ): TurnOutcomeEvent => ({
134
+ ...base,
135
+ outcome,
136
+ ...(evidence ? { detail: evidence.slice(0, DETAIL_MAX) } : {}),
137
+ ...(retryAt ? { retryAt } : {}),
138
+ });
139
+
140
+ // 1) Structured HTTP status.
141
+ if (meta.apiErrorStatus === 401 || meta.apiErrorStatus === 403) return withDetail('auth');
142
+ if (meta.apiErrorStatus === 413) return withDetail('context_overflow');
143
+ if (meta.apiErrorStatus === 429) {
144
+ return withDetail('usage_limit', evidence ? parseClaudeResetTime(evidence, now) : undefined);
145
+ }
146
+
147
+ // 2) Text families — refine only; they can never turn a failure into ok.
148
+ const failed =
149
+ meta.isError ||
150
+ // Claude CLI 2.1.220 sets terminal_reason=completed on clean result
151
+ // frames. Any other terminal reason stays fail-closed.
152
+ (meta.terminalReason !== undefined && meta.terminalReason !== 'completed') ||
153
+ (meta.subtype !== undefined && meta.subtype !== 'success');
154
+ const syntheticQuiet =
155
+ !failed &&
156
+ // A "successful" turn that never touched the API and produced no tool
157
+ // activity is the synthetic-notice shape (limit/auth text emitted
158
+ // locally by the CLI): zero usage + stop_sequence.
159
+ meta.stopReason === 'stop_sequence' &&
160
+ (meta.inputTokens ?? 0) === 0 &&
161
+ (meta.outputTokens ?? 0) === 0 &&
162
+ (meta.durationApiMs ?? 0) === 0;
163
+ if (failed || syntheticQuiet || noticeTexts.length > 0) {
164
+ if (CONTEXT_TEXT.test(evidence)) return withDetail('context_overflow');
165
+ if (LIMIT_TEXT.test(evidence)) {
166
+ return withDetail('usage_limit', parseClaudeResetTime(evidence, now));
167
+ }
168
+ if (AUTH_TEXT.test(evidence)) return withDetail('auth');
169
+ // Reached only when this block was entered (failed / syntheticQuiet /
170
+ // a captured synthetic notice) but no text family matched. A CLI wording
171
+ // change must degrade to api_error — never fall through to `ok` and settle
172
+ // a refused turn as success (the read-no-reply class this PR closes).
173
+ return withDetail('api_error');
174
+ }
175
+
176
+ return { ...base, outcome: 'ok' };
177
+ }