@parall/claude-agent 1.58.2 → 1.60.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.
@@ -1,5 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import type { DispatchInputLifecycle, GatewayLogger, RuntimeInputState } from '@parall/agent-core';
3
+ import {
4
+ type ClaudeTurnSink,
5
+ newTurnEvidence,
6
+ newTurnSink,
7
+ type TurnEvidence,
8
+ } from './turn-sink.js';
3
9
 
4
10
  export type ClaudeInputDelivery = {
5
11
  deliveryKey: string;
@@ -10,6 +16,14 @@ export type ClaudeInputDelivery = {
10
16
  terminal?: 'completed' | 'failed' | 'settled';
11
17
  reportedState?: RuntimeInputState;
12
18
  resultFailed: boolean;
19
+ /** Envelopes the stdout pump routed to this delivery; drained by its dispatch. */
20
+ sink: ClaudeTurnSink;
21
+ /** Turn-outcome evidence gathered while this delivery owned stdout. */
22
+ evidence: TurnEvidence;
23
+ /** Dispatch inactivity signal of the dispatch draining (or about to drain) this delivery. */
24
+ noteActivity?: () => void;
25
+ /** runtime_session already pushed to this delivery's sink. */
26
+ sessionAnnounced: boolean;
13
27
  /**
14
28
  * The turn settled as usage_limit: the lane-level deferred complete will
15
29
  * re-deliver this input at retry_at without burning redrive budget. A
@@ -55,21 +69,42 @@ export class ClaudeInputRegistry {
55
69
  return [...this.byKey.values()].some((delivery) => delivery.injected && !delivery.drained);
56
70
  }
57
71
 
72
+ /**
73
+ * Injected inputs whose lifecycle has NOT reached a terminal state. A
74
+ * terminal delivery still counts as pending (its buffered copy owes frame
75
+ * boundary bookkeeping) but owes the server nothing — the lane complete
76
+ * defers on this predicate, never on hasPendingInjections, so a bookkeeping
77
+ * copy that never drains cannot hold the lane open past its lease. Drained
78
+ * is deliberately NOT part of this predicate: a discarded bookkeeping copy
79
+ * (drained, non-terminal) is still a running input — completing its lane
80
+ * would release the member for redrive mid-processing.
81
+ */
82
+ hasUnsettledInjections(): boolean {
83
+ return [...this.byKey.values()].some((delivery) => delivery.injected && !delivery.terminal);
84
+ }
85
+
58
86
  register(
59
87
  deliveryKey: string,
60
88
  lifecycle: DispatchInputLifecycle | undefined,
61
89
  injected: boolean,
90
+ noteActivity?: () => void,
91
+ log?: GatewayLogger,
62
92
  ): ClaudeInputDelivery {
63
93
  if (this.byKey.has(deliveryKey)) {
64
94
  throw new Error(`duplicate Claude delivery key ${deliveryKey}`);
65
95
  }
96
+ const commandUuid = randomUUID();
66
97
  const delivery: ClaudeInputDelivery = {
67
98
  deliveryKey,
68
- commandUuid: randomUUID(),
99
+ commandUuid,
69
100
  lifecycle,
70
101
  injected,
71
102
  drained: !injected,
72
103
  resultFailed: false,
104
+ sink: newTurnSink(`delivery ${deliveryKey}`, log),
105
+ evidence: newTurnEvidence(),
106
+ noteActivity,
107
+ sessionAnnounced: false,
73
108
  };
74
109
  this.byKey.set(deliveryKey, delivery);
75
110
  this.byCommand.set(delivery.commandUuid, delivery);
@@ -83,6 +118,25 @@ export class ClaudeInputRegistry {
83
118
  if (this.byCommand.get(delivery.commandUuid) === delivery) {
84
119
  this.byCommand.delete(delivery.commandUuid);
85
120
  }
121
+ // The sink stays open: apply() removes a drained delivery at its
122
+ // terminal BEFORE the pump pushes the terminal envelope its drain is
123
+ // waiting for. Only the pump closes sinks (process gone).
124
+ }
125
+
126
+ /**
127
+ * The buffered copy backing this delivery was discarded without a
128
+ * dispatch (already rendered into a frame the model saw), so the normal
129
+ * bookkeeping drain never comes. Its frame-boundary obligation is void:
130
+ * mark it drained so nothing waits on it, and drop it once terminal. A
131
+ * still-running input keeps its registration — byCommand must keep routing
132
+ * its lifecycle frames — and is dropped when its terminal state arrives
133
+ * (see apply/fail).
134
+ */
135
+ discardBookkeeping(deliveryKey: string): void {
136
+ const delivery = this.byKey.get(deliveryKey);
137
+ if (!delivery) return;
138
+ delivery.drained = true;
139
+ if (delivery.terminal) this.remove(delivery);
86
140
  }
87
141
 
88
142
  async apply(
@@ -108,12 +162,17 @@ export class ClaudeInputRegistry {
108
162
  delivery.reportedState = 'completed';
109
163
  }
110
164
  delivery.terminal = 'completed';
165
+ // A discarded bookkeeping copy (drained without a dispatch) has no
166
+ // drain left to remove this entry — drop it at its terminal so it can
167
+ // never shadow a redelivered batch in findOverlapping.
168
+ if (delivery.drained) this.remove(delivery);
111
169
  }
112
170
 
113
171
  async fail(delivery: ClaudeInputDelivery): Promise<void> {
114
172
  if (delivery.terminal) return;
115
173
  if (delivery.suppressFailReport) {
116
174
  delivery.terminal = 'settled';
175
+ if (delivery.drained) this.remove(delivery);
117
176
  return;
118
177
  }
119
178
  try {
@@ -124,6 +183,7 @@ export class ClaudeInputRegistry {
124
183
  }
125
184
  } finally {
126
185
  delivery.terminal ??= 'failed';
186
+ if (delivery.drained) this.remove(delivery);
127
187
  }
128
188
  }
129
189
 
@@ -27,9 +27,52 @@ export type ClaudeResultMeta = {
27
27
  model?: string;
28
28
  };
29
29
 
30
+ /**
31
+ * The CLI's background-work frames (`system` subtypes). Every field is
32
+ * optional except the subtype.
33
+ * Observed on 2.1.227/2.1.258: `background_tasks_changed` (REPLACE
34
+ * semantics — every live task after the change), `task_started`,
35
+ * `task_progress`, `task_updated`, `task_notification` (a task finished or
36
+ * was stopped), `status`, `session_state_changed` (env-gated).
37
+ */
38
+ export type ClaudeRuntimeTaskEvent = {
39
+ type: 'runtime_task';
40
+ subtype:
41
+ | 'background_tasks_changed'
42
+ | 'task_started'
43
+ | 'task_progress'
44
+ | 'task_updated'
45
+ | 'task_notification'
46
+ | 'status'
47
+ | 'session_state_changed';
48
+ taskId?: string;
49
+ toolUseId?: string;
50
+ description?: string;
51
+ taskType?: string;
52
+ status?: string;
53
+ summary?: string;
54
+ isBackgrounded?: boolean;
55
+ tasks?: Array<{ taskId: string; taskType?: string; description?: string; ambient?: boolean }>;
56
+ };
57
+
58
+ const RUNTIME_TASK_SUBTYPES = new Set<ClaudeRuntimeTaskEvent['subtype']>([
59
+ 'background_tasks_changed',
60
+ 'task_started',
61
+ 'task_progress',
62
+ 'task_updated',
63
+ 'task_notification',
64
+ 'status',
65
+ 'session_state_changed',
66
+ ]);
67
+
30
68
  export type ClaudeParsedEvent =
31
69
  | RuntimeEvent
32
70
  | { type: 'runtime_init'; sessionId?: string; capabilities: string[] }
71
+ | ClaudeRuntimeTaskEvent
72
+ // A root `user` frame that carries text instead of tool results (the
73
+ // CLI's own injected notices, e.g. a `<task-notification>`). Never a
74
+ // RuntimeEvent: the model's turn already carries its consequence.
75
+ | { type: 'user_text'; text: string }
33
76
  // Internal-only progress marker for a valid CLI frame whose content must
34
77
  // not be projected into the root RuntimeEvent stream (for example, nested
35
78
  // subagent output). The dispatch consumer uses it only to refresh runtime
@@ -44,6 +87,10 @@ export type ClaudeParsedEvent =
44
87
  // is not a model message — surfaced so the turn-outcome classifier can see
45
88
  // the text without it entering the RuntimeEvent stream.
46
89
  | { type: 'assistant_error'; message: string }
90
+ // `system` frame with subtype `compact_boundary`: the CLI folded history
91
+ // (a manual `/compact` — the idle auto-compact path — or its own
92
+ // auto-compact mid-turn). Evidence only; never a RuntimeEvent.
93
+ | { type: 'compact_boundary'; trigger?: string; preTokens?: number }
47
94
  // turn_end is emitted for every `result` frame (both `is_error: true` and
48
95
  // `is_error: false`); `isError` carries the frame's status so the consumer
49
96
  // can tell a clean turn boundary from a failed one without inspecting the
@@ -169,21 +216,49 @@ export async function* parseClaudeStreamJson(
169
216
  const eventTimestampMs = parseEventTimestampMs(event) ?? now;
170
217
  const eventRecord = event as Record<string, unknown>;
171
218
 
172
- if (eventRecord.type === 'system' && eventRecord.subtype === 'init') {
173
- const sessionId = asTrimmedString(eventRecord.session_id);
174
- const capabilities = Array.isArray(eventRecord.capabilities)
175
- ? eventRecord.capabilities
176
- .map((capability) => asTrimmedString(capability))
177
- .filter((capability): capability is string => Boolean(capability))
178
- : [];
219
+ if (eventRecord.type === 'system' && eventRecord.subtype === 'compact_boundary') {
220
+ const meta =
221
+ eventRecord.compact_metadata && typeof eventRecord.compact_metadata === 'object'
222
+ ? (eventRecord.compact_metadata as Record<string, unknown>)
223
+ : undefined;
224
+ const trigger = asTrimmedString(meta?.trigger);
225
+ const preTokens = asFiniteNumber(meta?.pre_tokens);
179
226
  yield {
180
- type: 'runtime_init',
181
- ...(sessionId ? { sessionId } : {}),
182
- capabilities,
227
+ type: 'compact_boundary',
228
+ ...(trigger ? { trigger } : {}),
229
+ ...(preTokens !== undefined ? { preTokens } : {}),
183
230
  };
184
231
  continue;
185
232
  }
186
233
 
234
+ if (eventRecord.type === 'system') {
235
+ if (eventRecord.subtype === 'init') {
236
+ const sessionId = asTrimmedString(eventRecord.session_id);
237
+ const capabilities = Array.isArray(eventRecord.capabilities)
238
+ ? eventRecord.capabilities
239
+ .map((capability) => asTrimmedString(capability))
240
+ .filter((capability): capability is string => Boolean(capability))
241
+ : [];
242
+ yield {
243
+ type: 'runtime_init',
244
+ ...(sessionId ? { sessionId } : {}),
245
+ capabilities,
246
+ };
247
+ continue;
248
+ }
249
+ const subtype = asTrimmedString(eventRecord.subtype) as
250
+ | ClaudeRuntimeTaskEvent['subtype']
251
+ | undefined;
252
+ if (subtype && RUNTIME_TASK_SUBTYPES.has(subtype)) {
253
+ yield parseRuntimeTask(subtype, eventRecord);
254
+ continue;
255
+ }
256
+ // hook_started / hook_response / thinking_tokens /
257
+ // ...: valid CLI progress with no projection.
258
+ yield { type: 'runtime_activity' };
259
+ continue;
260
+ }
261
+
187
262
  if (eventRecord.type === 'command_lifecycle') {
188
263
  const commandUuid = asTrimmedString(eventRecord.command_uuid);
189
264
  const state = asTrimmedString(eventRecord.state);
@@ -277,7 +352,17 @@ export async function* parseClaudeStreamJson(
277
352
  message && typeof message === 'object'
278
353
  ? (message as { content?: unknown }).content
279
354
  : undefined;
355
+ if (typeof content === 'string') {
356
+ const text = content.trim();
357
+ if (text) yield { type: 'user_text', text };
358
+ continue;
359
+ }
280
360
  if (!Array.isArray(content)) continue;
361
+ if (!content.some((block) => (block as { type?: unknown })?.type === 'tool_result')) {
362
+ const text = stringifyContent(content).trim();
363
+ if (text) yield { type: 'user_text', text };
364
+ continue;
365
+ }
281
366
 
282
367
  for (const block of content) {
283
368
  if (!block || typeof block !== 'object') continue;
@@ -329,8 +414,53 @@ export async function* parseClaudeStreamJson(
329
414
  ...(numTurns !== undefined ? { numTurns } : {}),
330
415
  resultMeta: extractResultMeta(eventRecord, isError, numTurns),
331
416
  };
417
+ continue;
418
+ }
419
+
420
+ // Any other well-formed frame (rate_limit_event, stream_event, ...) is
421
+ // CLI progress: refresh activity, never project.
422
+ yield { type: 'runtime_activity' };
423
+ }
424
+ }
425
+
426
+ function parseRuntimeTask(
427
+ subtype: ClaudeRuntimeTaskEvent['subtype'],
428
+ frame: Record<string, unknown>,
429
+ ): ClaudeRuntimeTaskEvent {
430
+ const event: ClaudeRuntimeTaskEvent = { type: 'runtime_task', subtype };
431
+ const taskId = asTrimmedString(frame.task_id);
432
+ if (taskId) event.taskId = taskId;
433
+ const toolUseId = asTrimmedString(frame.tool_use_id);
434
+ if (toolUseId) event.toolUseId = toolUseId;
435
+ const description = asTrimmedString(frame.description);
436
+ if (description) event.description = description;
437
+ const taskType = asTrimmedString(frame.task_type);
438
+ if (taskType) event.taskType = taskType;
439
+ const status =
440
+ asTrimmedString(frame.status) ??
441
+ asTrimmedString((frame.patch as { status?: unknown } | undefined)?.status) ??
442
+ asTrimmedString(frame.state);
443
+ if (status) event.status = status;
444
+ const summary = asTrimmedString(frame.summary);
445
+ if (summary) event.summary = summary;
446
+ if (typeof frame.is_backgrounded === 'boolean') event.isBackgrounded = frame.is_backgrounded;
447
+ if (Array.isArray(frame.tasks)) {
448
+ event.tasks = [];
449
+ for (const task of frame.tasks) {
450
+ if (!task || typeof task !== 'object') continue;
451
+ const record = task as Record<string, unknown>;
452
+ const id = asTrimmedString(record.task_id);
453
+ if (!id) continue;
454
+ const entry: NonNullable<ClaudeRuntimeTaskEvent['tasks']>[number] = { taskId: id };
455
+ const entryType = asTrimmedString(record.task_type);
456
+ if (entryType) entry.taskType = entryType;
457
+ const entryDescription = asTrimmedString(record.description);
458
+ if (entryDescription) entry.description = entryDescription;
459
+ if (record.ambient === true) entry.ambient = true;
460
+ event.tasks.push(entry);
332
461
  }
333
462
  }
463
+ return event;
334
464
  }
335
465
 
336
466
  /** Snapshot the result frame's discriminator + usage fields (all optional). */