@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.
@@ -0,0 +1,159 @@
1
+ import type { RuntimeBusyState } from '@parall/agent-core';
2
+ import type { ClaudeRuntimeTaskEvent } from './output-parser.js';
3
+
4
+ export type ClaudeBackgroundTask = {
5
+ taskId: string;
6
+ description?: string;
7
+ /** Ambient tasks (e.g. Monitor) never finish on their own. */
8
+ ambient: boolean;
9
+ };
10
+
11
+ export type ClaudeTaskNotification = {
12
+ taskId?: string;
13
+ status?: string;
14
+ summary?: string;
15
+ description?: string;
16
+ at: number;
17
+ };
18
+
19
+ /** Default follow-up hold after a background task finishes (ms). */
20
+ export const DEFAULT_FOLLOW_UP_HOLD_MS = 30_000;
21
+
22
+ /**
23
+ * Per-process background-work ledger fed by the CLI's task frames
24
+ * (`system/background_tasks_changed`, `task_started`, `task_notification`).
25
+ *
26
+ * `busy` is: a turn is running, OR a background task finished within the
27
+ * follow-up hold and the CLI has not yet started (or folded) the follow-up
28
+ * turn it usually runs on that notification. Outstanding background tasks by
29
+ * themselves are NOT busy — a `make dev` may run forever and must never
30
+ * block a restart or a shutdown drain.
31
+ */
32
+ export class ClaudeBusyTracker {
33
+ readonly backgroundTasks = new Map<string, ClaudeBackgroundTask>();
34
+ followUpHoldUntil: number | null = null;
35
+ lastNotification: ClaudeTaskNotification | undefined;
36
+ /**
37
+ * Descriptions of tasks that already left the live set: the REPLACE frame
38
+ * arrives BEFORE the task_notification for the task it removed, and the
39
+ * notification itself carries only a summary.
40
+ */
41
+ private readonly finishedDescriptions = new Map<string, { description: string; at: number }>();
42
+
43
+ constructor(private readonly holdMs: number = DEFAULT_FOLLOW_UP_HOLD_MS) {}
44
+
45
+ onTaskFrame(frame: ClaudeRuntimeTaskEvent, now = Date.now()): void {
46
+ switch (frame.subtype) {
47
+ case 'background_tasks_changed': {
48
+ // REPLACE semantics: the frame lists every live task after the change.
49
+ const next = new Map<string, ClaudeBackgroundTask>();
50
+ for (const task of frame.tasks ?? []) {
51
+ const prior = this.backgroundTasks.get(task.taskId);
52
+ next.set(task.taskId, {
53
+ taskId: task.taskId,
54
+ description: task.description ?? prior?.description,
55
+ ambient: task.ambient ?? prior?.ambient ?? false,
56
+ });
57
+ }
58
+ for (const [id, task] of this.backgroundTasks) {
59
+ if (!next.has(id) && task.description) {
60
+ this.finishedDescriptions.set(id, { description: task.description, at: now });
61
+ }
62
+ }
63
+ // Nothing older than takeRecentNotification's horizon is ever read
64
+ // again: tasks that ended without a notification must not accumulate.
65
+ for (const [id, entry] of this.finishedDescriptions) {
66
+ if (now - entry.at > this.holdMs * 2) this.finishedDescriptions.delete(id);
67
+ }
68
+ this.backgroundTasks.clear();
69
+ for (const [id, task] of next) this.backgroundTasks.set(id, task);
70
+ return;
71
+ }
72
+ case 'task_started': {
73
+ if (!frame.taskId || frame.isBackgrounded === false) return;
74
+ const prior = this.backgroundTasks.get(frame.taskId);
75
+ this.backgroundTasks.set(frame.taskId, {
76
+ taskId: frame.taskId,
77
+ description: frame.description ?? prior?.description,
78
+ ambient: prior?.ambient ?? false,
79
+ });
80
+ return;
81
+ }
82
+ case 'task_notification': {
83
+ const prior = frame.taskId ? this.backgroundTasks.get(frame.taskId) : undefined;
84
+ const finished = frame.taskId ? this.finishedDescriptions.get(frame.taskId) : undefined;
85
+ if (frame.taskId) {
86
+ this.backgroundTasks.delete(frame.taskId);
87
+ this.finishedDescriptions.delete(frame.taskId);
88
+ }
89
+ this.lastNotification = {
90
+ taskId: frame.taskId,
91
+ status: frame.status,
92
+ summary: frame.summary,
93
+ description: prior?.description ?? finished?.description ?? frame.description,
94
+ at: now,
95
+ };
96
+ // Unconditional: if the notification folds into a running turn the
97
+ // hold expires harmlessly (bounded by holdMs).
98
+ this.followUpHoldUntil = now + this.holdMs;
99
+ return;
100
+ }
101
+ default:
102
+ return;
103
+ }
104
+ }
105
+
106
+ /** A turn started (dispatch or runtime-initiated): the hold did its job. */
107
+ clearHold(): void {
108
+ this.followUpHoldUntil = null;
109
+ }
110
+
111
+ holdActive(now = Date.now()): boolean {
112
+ return this.activeHoldUntil(now) !== undefined;
113
+ }
114
+
115
+ /** The hold's expiry while it is still pending, else undefined. */
116
+ activeHoldUntil(now = Date.now()): number | undefined {
117
+ return this.followUpHoldUntil !== null && this.followUpHoldUntil > now
118
+ ? this.followUpHoldUntil
119
+ : undefined;
120
+ }
121
+
122
+ /** The notification a runtime-initiated turn most plausibly follows. */
123
+ takeRecentNotification(now = Date.now()): ClaudeTaskNotification | undefined {
124
+ const notification = this.lastNotification;
125
+ this.lastNotification = undefined;
126
+ if (!notification) return undefined;
127
+ // A notification older than two hold windows is not "recent" — the
128
+ // turn opening now is more likely unrelated; report it as such.
129
+ return now - notification.at <= this.holdMs * 2 ? notification : undefined;
130
+ }
131
+
132
+ outstanding(): { total: number; ambient: number } {
133
+ let ambient = 0;
134
+ for (const task of this.backgroundTasks.values()) if (task.ambient) ambient += 1;
135
+ return { total: this.backgroundTasks.size, ambient };
136
+ }
137
+
138
+ /** Background shells die with the process. */
139
+ reset(): void {
140
+ this.backgroundTasks.clear();
141
+ this.finishedDescriptions.clear();
142
+ this.followUpHoldUntil = null;
143
+ this.lastNotification = undefined;
144
+ }
145
+ }
146
+
147
+ export function aggregateBusyState(states: RuntimeBusyState[]): RuntimeBusyState {
148
+ let activeTurns = 0;
149
+ let backgroundWork = 0;
150
+ let holdUntil: number | undefined;
151
+ for (const state of states) {
152
+ activeTurns += state.activeTurns;
153
+ backgroundWork += state.backgroundWork;
154
+ if (state.holdUntil !== undefined && (holdUntil === undefined || state.holdUntil > holdUntil)) {
155
+ holdUntil = state.holdUntil;
156
+ }
157
+ }
158
+ return { activeTurns, backgroundWork, ...(holdUntil !== undefined ? { holdUntil } : {}) };
159
+ }
package/src/compact.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { CompactOpts, CompactResult, GatewayLogger } from '@parall/agent-core';
3
+ import type { ProcessState } from './dispatch.js';
4
+ import type { ClaudeInputDelivery } from './input-lifecycle.js';
5
+ import type { ClaudeProcessHandle } from './session-manager.js';
6
+
7
+ /**
8
+ * Idle auto-compact on the Claude bridge (idle-auto-compact-design.md §3.4):
9
+ * write a `/compact` user frame into the long-lived process — the Agent
10
+ * SDK's documented way to run the built-in — and wait for that command's
11
+ * lifecycle to settle. The process pump (process-pump.ts) is the only
12
+ * stdout reader: the command is registered like a dispatch delivery, the
13
+ * pump routes its frames into the delivery's sink and records the evidence
14
+ * on it — a `system/compact_boundary` means the CLI folded history (`done`);
15
+ * a result without one is the CLI declining ("Not enough messages to
16
+ * compact." — `noop`). The gateway holds the lane, so no dispatch shares the
17
+ * process while this runs. Nothing here persists steps, sends messages or
18
+ * touches session status.
19
+ *
20
+ * The host is the narrow slice of ClaudeCodeAdapter's process bookkeeping
21
+ * this needs; the adapter's `compact()` is a thin delegate.
22
+ */
23
+ export interface ClaudeCompactHost {
24
+ ensureRuntimeCapability(log: GatewayLogger | undefined): Promise<void>;
25
+ ensureProcess(sessionKey: string, log: GatewayLogger | undefined): ProcessState;
26
+ killProcess(sessionKey: string, state: ProcessState): void;
27
+ writeUserMessage(handle: ClaudeProcessHandle, text: string, commandUuid: string): void;
28
+ /** Lazy restart re-check once the command settled (the adapter's own gate). */
29
+ applyPendingRestart(sessionKey: string, state: ProcessState): void;
30
+ }
31
+
32
+ export async function runClaudeCompact(
33
+ host: ClaudeCompactHost,
34
+ { sessionKey, signal, log }: CompactOpts,
35
+ ): Promise<CompactResult> {
36
+ if (signal.aborted) return { status: 'timeout' };
37
+ let state: ProcessState;
38
+ try {
39
+ await host.ensureRuntimeCapability(log);
40
+ state = host.ensureProcess(sessionKey, log);
41
+ } catch (err) {
42
+ return { status: 'failed', detail: `Claude spawn failed: ${String(err)}` };
43
+ }
44
+ // Defensive: an injection still owing bookkeeping means a dispatch is
45
+ // not actually settled on this session — the gateway never gets here in
46
+ // that state, but a compaction must not steal its frames.
47
+ if (state.inputs.hasPendingInjections() || state.inputs.hasUnsettledInjections()) {
48
+ return { status: 'failed', detail: 'injections still pending on the session' };
49
+ }
50
+ const delivery = state.inputs.register(`compact:${randomUUID()}`, undefined, false);
51
+ try {
52
+ host.writeUserMessage(state.handle, '/compact', delivery.commandUuid);
53
+ } catch (err) {
54
+ state.inputs.remove(delivery);
55
+ host.killProcess(sessionKey, state);
56
+ return { status: 'failed', detail: `Claude stdin write failed: ${String(err)}` };
57
+ }
58
+ try {
59
+ return await consumeCompact(host, sessionKey, state, delivery, signal, log);
60
+ } finally {
61
+ state.inputs.remove(delivery);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Drain the command's sink until its lifecycle settles. The pump already
67
+ * did the process bookkeeping (init, lifecycle, EOF); this only reads the
68
+ * evidence it left on the delivery. On abort the process is SIGTERMed — the
69
+ * session survives via --resume on the next spawn — and the pump's `killed`
70
+ * envelope reports `timeout`.
71
+ */
72
+ async function consumeCompact(
73
+ host: ClaudeCompactHost,
74
+ sessionKey: string,
75
+ state: ProcessState,
76
+ target: ClaudeInputDelivery,
77
+ signal: AbortSignal,
78
+ log: GatewayLogger | undefined,
79
+ ): Promise<CompactResult> {
80
+ const onAbort = () => host.killProcess(sessionKey, state);
81
+ signal.addEventListener('abort', onAbort, { once: true });
82
+ // The budget may have expired while the process was being spawned or the
83
+ // frame written; an abort that already fired never reaches the listener,
84
+ // and without the kill the sink drain below would never settle — and the
85
+ // gateway's main-lane hold would leak with it.
86
+ if (signal.aborted) onAbort();
87
+ try {
88
+ while (true) {
89
+ const next = await target.sink.next();
90
+ if (next.done) break;
91
+ const envelope = next.value;
92
+ if (envelope.kind === 'runtime') continue; // the summary's own frames: progress only
93
+ if (envelope.kind === 'terminal') break;
94
+ // The process is gone (eof / killed) or the pump ended this window.
95
+ const detail = state.handle.stderrChunks.join('').trim();
96
+ if (detail) log?.warn?.(`subprocess stderr: ${detail}`);
97
+ if (signal.aborted) return { status: 'timeout', detail: detail || undefined };
98
+ const exit = await state.handle.exitPromise.catch(
99
+ () => ({ code: null, signal: null }) as const,
100
+ );
101
+ return {
102
+ status: 'failed',
103
+ detail:
104
+ detail ||
105
+ envelope.message ||
106
+ `Claude exited with code ${exit.code ?? 'unknown'}${exit.signal ? ` (${exit.signal})` : ''}`,
107
+ };
108
+ }
109
+ } finally {
110
+ signal.removeEventListener('abort', onAbort);
111
+ }
112
+ host.applyPendingRestart(sessionKey, state);
113
+
114
+ const { compactBoundary, zeroTurnResultMeta, lastResultMeta } = target.evidence;
115
+ // The /compact result frame reports num_turns 0 (no model turn ran on the
116
+ // user's behalf): it is this command's boundary, not the resume poison
117
+ // frame, so it is read from the zero-turn slot first.
118
+ const result = zeroTurnResultMeta ?? lastResultMeta;
119
+ if (compactBoundary) {
120
+ return {
121
+ status: 'done',
122
+ ...(compactBoundary.preTokens !== undefined ? { preTokens: compactBoundary.preTokens } : {}),
123
+ };
124
+ }
125
+ if (result?.isError) {
126
+ return { status: 'failed', detail: result.resultText || 'compact result reported an error' };
127
+ }
128
+ if (target.terminal !== 'completed') {
129
+ return { status: 'failed', detail: `compact command ${target.terminal}` };
130
+ }
131
+ return { status: 'noop', detail: result?.resultText || 'no compact boundary emitted' };
132
+ }