@parall/claude-agent 1.51.0 → 1.52.1

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,39 @@
1
+ import type { DispatchInputLifecycle, GatewayLogger, RuntimeInputState } from '@parall/agent-core';
2
+ export type ClaudeInputDelivery = {
3
+ deliveryKey: string;
4
+ commandUuid: string;
5
+ lifecycle?: DispatchInputLifecycle;
6
+ injected: boolean;
7
+ drained: boolean;
8
+ terminal?: 'completed' | 'failed' | 'settled';
9
+ reportedState?: RuntimeInputState;
10
+ resultFailed: boolean;
11
+ /**
12
+ * The turn settled as usage_limit: the lane-level deferred complete will
13
+ * re-deliver this input at retry_at without burning redrive budget. A
14
+ * failed-input report here would trigger the immediate failed-input
15
+ * redrive straight back into the still-choked LLM
16
+ * (agent-turn-outcome-design.md §6).
17
+ */
18
+ suppressFailReport?: boolean;
19
+ };
20
+ /**
21
+ * Per-process registry that maps Parall WorkItem batches to Claude stdin
22
+ * UUIDs. It owns lifecycle transition rules; process/stdout orchestration
23
+ * stays in ClaudeCodeAdapter.
24
+ */
25
+ export declare class ClaudeInputRegistry {
26
+ private readonly byKey;
27
+ private readonly byCommand;
28
+ getByKey(deliveryKey: string): ClaudeInputDelivery | undefined;
29
+ getByCommand(commandUuid: string): ClaudeInputDelivery | undefined;
30
+ values(): IterableIterator<ClaudeInputDelivery>;
31
+ hasPendingInjections(): boolean;
32
+ register(deliveryKey: string, lifecycle: DispatchInputLifecycle | undefined, injected: boolean): ClaudeInputDelivery;
33
+ remove(delivery: ClaudeInputDelivery): void;
34
+ apply(delivery: ClaudeInputDelivery, state: 'queued' | 'started' | 'completed' | 'cancelled' | 'discarded'): Promise<void>;
35
+ fail(delivery: ClaudeInputDelivery): Promise<void>;
36
+ failBestEffort(delivery: ClaudeInputDelivery, log?: GatewayLogger): Promise<void>;
37
+ failAllBestEffort(log?: GatewayLogger): Promise<void>;
38
+ }
39
+ //# sourceMappingURL=input-lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"input-lifecycle.d.ts","sourceRoot":"","sources":["../src/input-lifecycle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEnG,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC9C,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,YAAY,EAAE,OAAO,CAAC;IACtB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF;;;;GAIG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0C;IAChE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0C;IAEpE,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAI9D,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAIlE,MAAM,IAAI,gBAAgB,CAAC,mBAAmB,CAAC;IAI/C,oBAAoB,IAAI,OAAO;IAI/B,QAAQ,CACN,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,sBAAsB,GAAG,SAAS,EAC7C,QAAQ,EAAE,OAAO,GAChB,mBAAmB;IAiBtB,MAAM,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI;IASrC,KAAK,CACT,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACpE,OAAO,CAAC,IAAI,CAAC;IAsBV,IAAI,CAAC,QAAQ,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBlD,cAAc,CAAC,QAAQ,EAAE,mBAAmB,EAAE,GAAG,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAUjF,iBAAiB,CAAC,GAAG,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;CAO5D"}
@@ -0,0 +1,97 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * Per-process registry that maps Parall WorkItem batches to Claude stdin
4
+ * UUIDs. It owns lifecycle transition rules; process/stdout orchestration
5
+ * stays in ClaudeCodeAdapter.
6
+ */
7
+ export class ClaudeInputRegistry {
8
+ byKey = new Map();
9
+ byCommand = new Map();
10
+ getByKey(deliveryKey) {
11
+ return this.byKey.get(deliveryKey);
12
+ }
13
+ getByCommand(commandUuid) {
14
+ return this.byCommand.get(commandUuid);
15
+ }
16
+ values() {
17
+ return this.byKey.values();
18
+ }
19
+ hasPendingInjections() {
20
+ return [...this.byKey.values()].some((delivery) => delivery.injected && !delivery.drained);
21
+ }
22
+ register(deliveryKey, lifecycle, injected) {
23
+ if (this.byKey.has(deliveryKey)) {
24
+ throw new Error(`duplicate Claude delivery key ${deliveryKey}`);
25
+ }
26
+ const delivery = {
27
+ deliveryKey,
28
+ commandUuid: randomUUID(),
29
+ lifecycle,
30
+ injected,
31
+ drained: !injected,
32
+ resultFailed: false,
33
+ };
34
+ this.byKey.set(deliveryKey, delivery);
35
+ this.byCommand.set(delivery.commandUuid, delivery);
36
+ return delivery;
37
+ }
38
+ remove(delivery) {
39
+ if (this.byKey.get(delivery.deliveryKey) === delivery) {
40
+ this.byKey.delete(delivery.deliveryKey);
41
+ }
42
+ if (this.byCommand.get(delivery.commandUuid) === delivery) {
43
+ this.byCommand.delete(delivery.commandUuid);
44
+ }
45
+ }
46
+ async apply(delivery, state) {
47
+ if (delivery.terminal || state === 'queued')
48
+ return;
49
+ if (state === 'started') {
50
+ if (delivery.reportedState)
51
+ return;
52
+ await delivery.lifecycle?.update('started');
53
+ delivery.reportedState = 'started';
54
+ return;
55
+ }
56
+ if (state === 'cancelled' || state === 'discarded' || delivery.resultFailed) {
57
+ await this.fail(delivery);
58
+ return;
59
+ }
60
+ if (delivery.reportedState !== 'completed') {
61
+ await delivery.lifecycle?.update('completed');
62
+ delivery.reportedState = 'completed';
63
+ }
64
+ delivery.terminal = 'completed';
65
+ }
66
+ async fail(delivery) {
67
+ if (delivery.terminal)
68
+ return;
69
+ if (delivery.suppressFailReport) {
70
+ delivery.terminal = 'settled';
71
+ return;
72
+ }
73
+ try {
74
+ if (delivery.reportedState !== 'failed') {
75
+ const result = await delivery.lifecycle?.update('failed');
76
+ delivery.reportedState = 'failed';
77
+ delivery.terminal = result?.retry === false ? 'settled' : 'failed';
78
+ }
79
+ }
80
+ finally {
81
+ delivery.terminal ??= 'failed';
82
+ }
83
+ }
84
+ async failBestEffort(delivery, log) {
85
+ try {
86
+ await this.fail(delivery);
87
+ }
88
+ catch (err) {
89
+ log?.warn?.(`failed to report Claude input ${delivery.commandUuid} as failed: ${String(err)}`);
90
+ }
91
+ }
92
+ async failAllBestEffort(log) {
93
+ await Promise.all([...this.byKey.values()]
94
+ .filter((delivery) => !delivery.terminal)
95
+ .map((delivery) => this.failBestEffort(delivery, log)));
96
+ }
97
+ }
@@ -1,13 +1,48 @@
1
1
  import type { Readable } from 'node:stream';
2
2
  import type { RuntimeEvent } from '@parall/agent-core';
3
+ /**
4
+ * Structured snapshot of the `result` frame's LLM-layer discriminators and
5
+ * accounting — the raw material for turn-outcome classification
6
+ * (agent-turn-outcome-design.md §4.1). Every field is optional: older CLIs
7
+ * omit some, and classification degrades gracefully.
8
+ */
9
+ export type ClaudeResultMeta = {
10
+ subtype?: string;
11
+ terminalReason?: string;
12
+ apiErrorStatus?: number;
13
+ stopReason?: string;
14
+ /** The frame's `result` text — synthetic failure notices land here. */
15
+ resultText?: string;
16
+ isError: boolean;
17
+ numTurns?: number;
18
+ durationMs?: number;
19
+ durationApiMs?: number;
20
+ totalCostUsd?: number;
21
+ inputTokens?: number;
22
+ outputTokens?: number;
23
+ cacheReadTokens?: number;
24
+ cacheCreationTokens?: number;
25
+ /** First model key of `modelUsage` — the model the CLI actually used. */
26
+ model?: string;
27
+ };
3
28
  export type ClaudeParsedEvent = RuntimeEvent | {
4
- type: 'session_id';
5
- sessionId: string;
29
+ type: 'runtime_init';
30
+ sessionId?: string;
31
+ capabilities: string[];
32
+ } | {
33
+ type: 'command_lifecycle';
34
+ commandUuid: string;
35
+ state: 'queued' | 'started' | 'completed' | 'cancelled' | 'discarded';
36
+ } | {
37
+ type: 'assistant_error';
38
+ message: string;
6
39
  } | {
7
40
  type: 'turn_end';
8
41
  sessionId?: string;
42
+ userMessageUuid?: string;
9
43
  isError: boolean;
10
44
  numTurns?: number;
45
+ resultMeta?: ClaudeResultMeta;
11
46
  };
12
47
  export declare function parseClaudeStreamJson(readable: Readable): AsyncGenerator<ClaudeParsedEvent>;
13
48
  //# sourceMappingURL=output-parser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"output-parser.d.ts","sourceRoot":"","sources":["../src/output-parser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD,MAAM,MAAM,iBAAiB,GACzB,YAAY,GACZ;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAkBzC;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAgFlF,wBAAuB,qBAAqB,CAC1C,QAAQ,EAAE,QAAQ,GACjB,cAAc,CAAC,iBAAiB,CAAC,CAmInC"}
1
+ {"version":3,"file":"output-parser.d.ts","sourceRoot":"","sources":["../src/output-parser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,yEAAyE;IACzE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GACzB,YAAY,GACZ;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,EAAE,CAAA;CAAE,GACpE;IACE,IAAI,EAAE,mBAAmB,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;CACvE,GAID;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAqB5C;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC/B,CAAC;AAgFN,wBAAuB,qBAAqB,CAC1C,QAAQ,EAAE,QAAQ,GACjB,cAAc,CAAC,iBAAiB,CAAC,CA4KnC"}
@@ -93,13 +93,46 @@ export async function* parseClaudeStreamJson(readable) {
93
93
  const eventRecord = event;
94
94
  if (eventRecord.type === 'system' && eventRecord.subtype === 'init') {
95
95
  const sessionId = asTrimmedString(eventRecord.session_id);
96
- if (sessionId) {
97
- yield { type: 'session_id', sessionId };
96
+ const capabilities = Array.isArray(eventRecord.capabilities)
97
+ ? eventRecord.capabilities
98
+ .map((capability) => asTrimmedString(capability))
99
+ .filter((capability) => Boolean(capability))
100
+ : [];
101
+ yield {
102
+ type: 'runtime_init',
103
+ ...(sessionId ? { sessionId } : {}),
104
+ capabilities,
105
+ };
106
+ continue;
107
+ }
108
+ if (eventRecord.type === 'command_lifecycle') {
109
+ const commandUuid = asTrimmedString(eventRecord.command_uuid);
110
+ const state = asTrimmedString(eventRecord.state);
111
+ if (commandUuid &&
112
+ (state === 'queued' ||
113
+ state === 'started' ||
114
+ state === 'completed' ||
115
+ state === 'cancelled' ||
116
+ state === 'discarded')) {
117
+ yield { type: 'command_lifecycle', commandUuid, state };
98
118
  }
99
119
  continue;
100
120
  }
101
121
  if (eventRecord.type === 'assistant') {
102
- if (eventRecord.error)
122
+ if (eventRecord.error) {
123
+ // Not a model message — surface the notice text for turn-outcome
124
+ // classification instead of silently dropping the frame.
125
+ const message = asTrimmedString(eventRecord.error) ??
126
+ asTrimmedString(eventRecord.error?.message);
127
+ if (message)
128
+ yield { type: 'assistant_error', message };
129
+ continue;
130
+ }
131
+ // Verbose stream-json includes nested subagent frames. Their final
132
+ // tool_result returns through the root conversation; projecting their
133
+ // internal assistant/tool stream here would attribute background work
134
+ // to whichever root input currently owns stdout.
135
+ if (asTrimmedString(eventRecord.parent_tool_use_id))
103
136
  continue;
104
137
  const message = eventRecord.message;
105
138
  const content = message && typeof message === 'object'
@@ -147,6 +180,8 @@ export async function* parseClaudeStreamJson(readable) {
147
180
  continue;
148
181
  }
149
182
  if (eventRecord.type === 'user') {
183
+ if (asTrimmedString(eventRecord.parent_tool_use_id))
184
+ continue;
150
185
  const message = eventRecord.message;
151
186
  const content = message && typeof message === 'object'
152
187
  ? message.content
@@ -191,12 +226,68 @@ export async function* parseClaudeStreamJson(readable) {
191
226
  // the map grow unbounded or mislabel durations on id collisions.
192
227
  toolUses.clear();
193
228
  const numTurns = asFiniteNumber(eventRecord.num_turns);
229
+ const userMessageUuid = asTrimmedString(eventRecord.user_message_uuid) || asTrimmedString(eventRecord.command_uuid);
194
230
  yield {
195
231
  type: 'turn_end',
196
232
  sessionId: asTrimmedString(eventRecord.session_id),
233
+ ...(userMessageUuid ? { userMessageUuid } : {}),
197
234
  isError,
198
235
  ...(numTurns !== undefined ? { numTurns } : {}),
236
+ resultMeta: extractResultMeta(eventRecord, isError, numTurns),
199
237
  };
200
238
  }
201
239
  }
202
240
  }
241
+ /** Snapshot the result frame's discriminator + usage fields (all optional). */
242
+ function extractResultMeta(frame, isError, numTurns) {
243
+ const usage = frame.usage && typeof frame.usage === 'object'
244
+ ? frame.usage
245
+ : undefined;
246
+ const modelUsage = frame.modelUsage && typeof frame.modelUsage === 'object'
247
+ ? Object.keys(frame.modelUsage)
248
+ : [];
249
+ const meta = { isError };
250
+ if (numTurns !== undefined)
251
+ meta.numTurns = numTurns;
252
+ const subtype = asTrimmedString(frame.subtype);
253
+ if (subtype)
254
+ meta.subtype = subtype;
255
+ const terminalReason = asTrimmedString(frame.terminal_reason);
256
+ if (terminalReason)
257
+ meta.terminalReason = terminalReason;
258
+ const apiErrorStatus = asFiniteNumber(frame.api_error_status);
259
+ if (apiErrorStatus !== undefined)
260
+ meta.apiErrorStatus = apiErrorStatus;
261
+ const stopReason = asTrimmedString(frame.stop_reason);
262
+ if (stopReason)
263
+ meta.stopReason = stopReason;
264
+ const resultText = asTrimmedString(frame.result);
265
+ if (resultText)
266
+ meta.resultText = resultText;
267
+ const durationMs = asFiniteNumber(frame.duration_ms);
268
+ if (durationMs !== undefined)
269
+ meta.durationMs = durationMs;
270
+ const durationApiMs = asFiniteNumber(frame.duration_api_ms);
271
+ if (durationApiMs !== undefined)
272
+ meta.durationApiMs = durationApiMs;
273
+ const totalCostUsd = asFiniteNumber(frame.total_cost_usd);
274
+ if (totalCostUsd !== undefined)
275
+ meta.totalCostUsd = totalCostUsd;
276
+ if (usage) {
277
+ const inputTokens = asFiniteNumber(usage.input_tokens);
278
+ if (inputTokens !== undefined)
279
+ meta.inputTokens = inputTokens;
280
+ const outputTokens = asFiniteNumber(usage.output_tokens);
281
+ if (outputTokens !== undefined)
282
+ meta.outputTokens = outputTokens;
283
+ const cacheReadTokens = asFiniteNumber(usage.cache_read_input_tokens);
284
+ if (cacheReadTokens !== undefined)
285
+ meta.cacheReadTokens = cacheReadTokens;
286
+ const cacheCreationTokens = asFiniteNumber(usage.cache_creation_input_tokens);
287
+ if (cacheCreationTokens !== undefined)
288
+ meta.cacheCreationTokens = cacheCreationTokens;
289
+ }
290
+ if (modelUsage.length > 0)
291
+ meta.model = modelUsage[0];
292
+ return meta;
293
+ }
@@ -0,0 +1,10 @@
1
+ import type { TurnOutcomeEvent } from '@parall/agent-core';
2
+ import type { ClaudeResultMeta } from './output-parser.js';
3
+ export declare function parseClaudeResetTime(text: string, now: Date): string | undefined;
4
+ /**
5
+ * Classify one finished Claude turn from its result-frame snapshot plus any
6
+ * synthetic notice texts observed during the turn (assistant_error frames,
7
+ * zero-usage assistant text like the session-limit notice).
8
+ */
9
+ export declare function classifyClaudeTurn(meta: ClaudeResultMeta | undefined, noticeTexts: string[], now?: Date): TurnOutcomeEvent;
10
+ //# sourceMappingURL=turn-outcome.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-outcome.d.ts","sourceRoot":"","sources":["../src/turn-outcome.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAa,MAAM,oBAAoB,CAAC;AACtE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAwC3D,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,GAAG,MAAM,GAAG,SAAS,CA8ChF;AAcD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,gBAAgB,GAAG,SAAS,EAClC,WAAW,EAAE,MAAM,EAAE,EACrB,GAAG,GAAE,IAAiB,GACrB,gBAAgB,CAkElB"}
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Claude turn-outcome classifier (agent-turn-outcome-design.md §4.1).
3
+ *
4
+ * Detection order is a hard rule: structured fields first
5
+ * (api_error_status / terminal_reason / is_error), behavioral heuristics
6
+ * second (zero-usage synthetic turns), message regex last — and regex only
7
+ * ever refines a classification, so a CLI wording change degrades to the
8
+ * generic `api_error`, never to a wrong `ok` or a wrong deferral.
9
+ * Classification uncertainty must never defer: deferring a real message is
10
+ * costlier than losing one automatic recovery.
11
+ */
12
+ const LIMIT_TEXT = /you'?ve (hit|reached) your .*limit|usage limit reached|weekly limit/i;
13
+ const AUTH_TEXT = /not logged in|please run \/login|authentication_error|invalid api key|oauth token (has )?expired|\[action required\]/i;
14
+ const CONTEXT_TEXT = /prompt is too long|context (window|length) exceeded|request too large/i;
15
+ const DETAIL_MAX = 500;
16
+ /**
17
+ * Parse the reset time out of a limit notice like
18
+ * `You've hit your session limit · resets 1:20am (Asia/Shanghai)` /
19
+ * `… resets at 11pm (UTC)`. Returns the NEXT occurrence of that wall-clock
20
+ * time in the given zone as an ISO string, or undefined when the wording
21
+ * doesn't match — the server then applies its default deferral window.
22
+ */
23
+ function minutesOfDayInZone(fmt, at) {
24
+ const parts = Object.fromEntries(fmt
25
+ .formatToParts(at)
26
+ .filter((p) => p.type !== 'literal')
27
+ .map((p) => [p.type, Number(p.value)]));
28
+ // "24" can appear for midnight under hour12:false on some ICU versions.
29
+ const hour = parts.hour === 24 ? 0 : parts.hour;
30
+ return hour * 60 + parts.minute;
31
+ }
32
+ export function parseClaudeResetTime(text, now) {
33
+ const m = /resets\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(([^)]+)\)/i.exec(text);
34
+ if (!m)
35
+ return undefined;
36
+ let hour = Number(m[1]);
37
+ const minute = m[2] ? Number(m[2]) : 0;
38
+ const meridiem = m[3]?.toLowerCase();
39
+ const timeZone = m[4].trim();
40
+ if (!Number.isFinite(hour) || hour > 23 || minute > 59)
41
+ return undefined;
42
+ if (meridiem === 'pm' && hour < 12)
43
+ hour += 12;
44
+ if (meridiem === 'am' && hour === 12)
45
+ hour = 0;
46
+ // Intl throws on unknown zone names — treat that as no parse.
47
+ let fmt;
48
+ let nowMinutesOfDay;
49
+ try {
50
+ fmt = new Intl.DateTimeFormat('en-US', {
51
+ timeZone,
52
+ hour12: false,
53
+ hour: '2-digit',
54
+ minute: '2-digit',
55
+ });
56
+ nowMinutesOfDay = minutesOfDayInZone(fmt, now);
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ const targetMinutesOfDay = hour * 60 + minute;
62
+ let deltaMinutes = targetMinutesOfDay - nowMinutesOfDay;
63
+ if (deltaMinutes <= 0)
64
+ deltaMinutes += 24 * 60;
65
+ // Anchor on the real instant (drop seconds so the result lands exactly on
66
+ // the notice's minute), advance by the wall-clock delta, then CORRECT for
67
+ // any DST transition inside the interval: wall-clock minutes ≠ elapsed UTC
68
+ // minutes across a shift, so re-read the candidate's wall-clock in the
69
+ // zone and nudge by the difference (two rounds absorb a single transition;
70
+ // a spring-forward nonexistent time settles within an hour, which the
71
+ // server-side clamp tolerates).
72
+ const anchored = now.getTime() - (now.getSeconds() * 1000 + now.getMilliseconds());
73
+ let target = anchored + deltaMinutes * 60_000;
74
+ for (let i = 0; i < 2; i++) {
75
+ let diff = targetMinutesOfDay - minutesOfDayInZone(fmt, new Date(target));
76
+ if (diff > 720)
77
+ diff -= 1440;
78
+ if (diff < -720)
79
+ diff += 1440;
80
+ if (diff === 0)
81
+ break;
82
+ target += diff * 60_000;
83
+ }
84
+ if (target <= now.getTime())
85
+ target += 24 * 60 * 60_000;
86
+ return new Date(target).toISOString();
87
+ }
88
+ function usageOf(meta) {
89
+ const usage = {};
90
+ if (meta.inputTokens !== undefined)
91
+ usage.inputTokens = meta.inputTokens;
92
+ if (meta.outputTokens !== undefined)
93
+ usage.outputTokens = meta.outputTokens;
94
+ if (meta.cacheReadTokens !== undefined)
95
+ usage.cacheReadTokens = meta.cacheReadTokens;
96
+ if (meta.cacheCreationTokens !== undefined)
97
+ usage.cacheCreationTokens = meta.cacheCreationTokens;
98
+ if (meta.totalCostUsd !== undefined)
99
+ usage.costUsd = meta.totalCostUsd;
100
+ if (meta.durationMs !== undefined)
101
+ usage.durationMs = meta.durationMs;
102
+ if (meta.durationApiMs !== undefined)
103
+ usage.durationApiMs = meta.durationApiMs;
104
+ return Object.keys(usage).length > 0 ? usage : undefined;
105
+ }
106
+ /**
107
+ * Classify one finished Claude turn from its result-frame snapshot plus any
108
+ * synthetic notice texts observed during the turn (assistant_error frames,
109
+ * zero-usage assistant text like the session-limit notice).
110
+ */
111
+ export function classifyClaudeTurn(meta, noticeTexts, now = new Date()) {
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',
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 = (outcome, retryAt) => ({
131
+ ...base,
132
+ outcome,
133
+ ...(evidence ? { detail: evidence.slice(0, DETAIL_MAX) } : {}),
134
+ ...(retryAt ? { retryAt } : {}),
135
+ });
136
+ // 1) Structured HTTP status.
137
+ if (meta.apiErrorStatus === 401 || meta.apiErrorStatus === 403)
138
+ return withDetail('auth');
139
+ if (meta.apiErrorStatus === 413)
140
+ return withDetail('context_overflow');
141
+ if (meta.apiErrorStatus === 429) {
142
+ return withDetail('usage_limit', evidence ? parseClaudeResetTime(evidence, now) : undefined);
143
+ }
144
+ // 2) Text families — refine only; they can never turn a failure into ok.
145
+ const failed = meta.isError ||
146
+ // Claude CLI 2.1.220 sets terminal_reason=completed on clean result
147
+ // frames. Any other terminal reason stays fail-closed.
148
+ (meta.terminalReason !== undefined && meta.terminalReason !== 'completed') ||
149
+ (meta.subtype !== undefined && meta.subtype !== 'success');
150
+ const syntheticQuiet = !failed &&
151
+ // A "successful" turn that never touched the API and produced no tool
152
+ // activity is the synthetic-notice shape (limit/auth text emitted
153
+ // locally by the CLI): zero usage + stop_sequence.
154
+ meta.stopReason === 'stop_sequence' &&
155
+ (meta.inputTokens ?? 0) === 0 &&
156
+ (meta.outputTokens ?? 0) === 0 &&
157
+ (meta.durationApiMs ?? 0) === 0;
158
+ if (failed || syntheticQuiet || noticeTexts.length > 0) {
159
+ if (CONTEXT_TEXT.test(evidence))
160
+ return withDetail('context_overflow');
161
+ if (LIMIT_TEXT.test(evidence)) {
162
+ return withDetail('usage_limit', parseClaudeResetTime(evidence, now));
163
+ }
164
+ if (AUTH_TEXT.test(evidence))
165
+ return withDetail('auth');
166
+ // Reached only when this block was entered (failed / syntheticQuiet /
167
+ // a captured synthetic notice) but no text family matched. A CLI wording
168
+ // change must degrade to api_error — never fall through to `ok` and settle
169
+ // a refused turn as success (the read-no-reply class this PR closes).
170
+ return withDetail('api_error');
171
+ }
172
+ return { ...base, outcome: 'ok' };
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/claude-agent",
3
- "version": "1.51.0",
3
+ "version": "1.52.1",
4
4
  "description": "Claude Code bridge runtime for self-hosted Parall agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,9 +25,9 @@
25
25
  "src"
26
26
  ],
27
27
  "dependencies": {
28
- "@parall/agent-core": "1.51.0",
29
- "@parall/sdk": "1.51.0",
30
- "@parall/cli": "1.51.0"
28
+ "@parall/sdk": "1.52.1",
29
+ "@parall/agent-core": "1.52.1",
30
+ "@parall/cli": "1.52.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.0.0",