@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.
- package/dist/busy-state.d.ts +56 -0
- package/dist/busy-state.d.ts.map +1 -0
- package/dist/busy-state.js +140 -0
- package/dist/compact.d.ts +29 -0
- package/dist/compact.d.ts.map +1 -0
- package/dist/compact.js +98 -0
- package/dist/dispatch.d.ts +54 -6
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +193 -180
- package/dist/index.js +6 -2
- package/dist/input-lifecycle.d.ts +31 -1
- package/dist/input-lifecycle.d.ts.map +1 -1
- package/dist/input-lifecycle.js +50 -2
- package/dist/output-parser.d.ts +32 -0
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +106 -10
- package/dist/process-pump.d.ts +105 -0
- package/dist/process-pump.d.ts.map +1 -0
- package/dist/process-pump.js +436 -0
- package/dist/runtime-turn.d.ts +19 -0
- package/dist/runtime-turn.d.ts.map +1 -0
- package/dist/runtime-turn.js +59 -0
- package/dist/session-manager.d.ts +1 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +3 -0
- package/dist/turn-sink.d.ts +49 -0
- package/dist/turn-sink.d.ts.map +1 -0
- package/dist/turn-sink.js +9 -0
- package/package.json +4 -4
- package/src/busy-state.ts +159 -0
- package/src/compact.ts +132 -0
- package/src/dispatch.ts +217 -209
- package/src/index.ts +6 -1
- package/src/input-lifecycle.ts +61 -1
- package/src/output-parser.ts +140 -10
- package/src/process-pump.ts +493 -0
- package/src/runtime-turn.ts +80 -0
- package/src/session-manager.ts +4 -0
- package/src/turn-sink.ts +59 -0
|
@@ -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
|
+
}
|