@lucascouts/claude-agent-acp-plus 0.23.6 → 0.25.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/README.md +54 -0
- package/dist/acp-agent.d.ts +85 -2
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +286 -31
- package/dist/classifier-escalation.d.ts +158 -0
- package/dist/classifier-escalation.d.ts.map +1 -0
- package/dist/classifier-escalation.js +562 -0
- package/dist/permissions/options/shared.d.ts +8 -0
- package/dist/permissions/options/shared.d.ts.map +1 -1
- package/dist/permissions/options/shared.js +18 -0
- package/dist/permissions/presentation.d.ts +1 -0
- package/dist/permissions/presentation.d.ts.map +1 -1
- package/dist/permissions/presentation.js +1 -1
- package/dist/permissions/response.d.ts +6 -0
- package/dist/permissions/response.d.ts.map +1 -1
- package/dist/permissions/response.js +19 -0
- package/dist/task-feed.d.ts +195 -0
- package/dist/task-feed.d.ts.map +1 -0
- package/dist/task-feed.js +530 -0
- package/package.json +1 -1
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent's task feed: background shells, subagents, monitors and workflows,
|
|
3
|
+
* folded from the SDK's task messages into the `_claude/tasks` snapshot.
|
|
4
|
+
*
|
|
5
|
+
* The SDK reports tasks as a stream of bookends (`task_started`,
|
|
6
|
+
* `task_progress`, `task_updated`, `task_notification`) plus a level
|
|
7
|
+
* (`background_tasks_changed`). Zeo needs one table it can render, so
|
|
8
|
+
* `TaskFeed` keeps that table per session and renders it as a snapshot. The
|
|
9
|
+
* snapshot is a LEVEL, not a stream: every publish carries every live task,
|
|
10
|
+
* so a lost update is repaired by the next one, and the client replaces its
|
|
11
|
+
* list instead of merging.
|
|
12
|
+
*
|
|
13
|
+
* An ended task appears in exactly one successful snapshot with its final
|
|
14
|
+
* status, then drops out. `snapshot()` marks nothing; the publisher calls
|
|
15
|
+
* `markPublished()` only after the send carrying the endings succeeded, so a
|
|
16
|
+
* failed send never loses an ending.
|
|
17
|
+
*
|
|
18
|
+
* `TaskFeedPublisher` decides WHEN to publish: at most once per second during
|
|
19
|
+
* progress, at once on an ending, and once more after a burst so the latest
|
|
20
|
+
* values are not lost.
|
|
21
|
+
*
|
|
22
|
+
* A foreground shell task (`local_bash`, not backgrounded) is held back for
|
|
23
|
+
* `FOREGROUND_SHELL_HOLD_MS`: Claude Code registers every shell command as a
|
|
24
|
+
* task, so only one still running after the hold, or moved to the background,
|
|
25
|
+
* is published. One that ends while held is dropped without a trace. The feed
|
|
26
|
+
* stays clock-driven: the publisher's timer calls `promoteDue()`.
|
|
27
|
+
*
|
|
28
|
+
* Neither class ever copies a command line (`SDKTaskStartedMessage.prompt`)
|
|
29
|
+
* into an entry or a log line.
|
|
30
|
+
*/
|
|
31
|
+
/** The `session_info_update._meta` key the snapshot is published under. */
|
|
32
|
+
export const TASKS_META_KEY = "_claude/tasks";
|
|
33
|
+
/** How long a foreground shell task is held back before it is published. */
|
|
34
|
+
export const FOREGROUND_SHELL_HOLD_MS = 5000;
|
|
35
|
+
/** How long a running background task may be missing from the level before
|
|
36
|
+
* it is ended as `interrupted`. The level and the bookends race (the level
|
|
37
|
+
* may arrive first), so a real ending gets this long to land. */
|
|
38
|
+
export const RECONCILE_GRACE_MS = 2_000;
|
|
39
|
+
/** Minimum gap between two progress publishes of one session. */
|
|
40
|
+
export const PUBLISH_INTERVAL_MS = 1_000;
|
|
41
|
+
const TASK_TYPES = {
|
|
42
|
+
local_bash: "shell",
|
|
43
|
+
local_agent: "subagent",
|
|
44
|
+
local_workflow: "workflow",
|
|
45
|
+
mcp_task: "mcp",
|
|
46
|
+
monitor: "monitor",
|
|
47
|
+
};
|
|
48
|
+
function normaliseType(raw) {
|
|
49
|
+
if (!raw)
|
|
50
|
+
return "shell";
|
|
51
|
+
return TASK_TYPES[raw] ?? raw;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The one place the SDK's ending vocabulary becomes the wire's. The same stop
|
|
55
|
+
* arrives as `task_updated.patch.status = "killed"` and as
|
|
56
|
+
* `task_notification.status = "stopped"`; both are `stopped`. Returns null for
|
|
57
|
+
* a non-terminal status (`pending`, `running`, `paused`).
|
|
58
|
+
*/
|
|
59
|
+
export function normaliseStatus(status, reason) {
|
|
60
|
+
if (reason === "worker_restart")
|
|
61
|
+
return "interrupted";
|
|
62
|
+
switch (status) {
|
|
63
|
+
case "completed":
|
|
64
|
+
return "completed";
|
|
65
|
+
case "failed":
|
|
66
|
+
return "failed";
|
|
67
|
+
case "killed":
|
|
68
|
+
case "stopped":
|
|
69
|
+
return "stopped";
|
|
70
|
+
default:
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The SDK types usage as `number`; Zeo parses it as an unsigned integer and
|
|
75
|
+
* rejects the whole snapshot on one fractional or negative value. */
|
|
76
|
+
function wholeNumber(value) {
|
|
77
|
+
return Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
|
|
78
|
+
}
|
|
79
|
+
function usageOf(usage) {
|
|
80
|
+
if (!usage)
|
|
81
|
+
return null;
|
|
82
|
+
return {
|
|
83
|
+
tokens: wholeNumber(usage.total_tokens),
|
|
84
|
+
toolUses: wholeNumber(usage.tool_uses),
|
|
85
|
+
durationMs: wholeNumber(usage.duration_ms),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export class TaskFeed {
|
|
89
|
+
clock;
|
|
90
|
+
log;
|
|
91
|
+
sessionId;
|
|
92
|
+
tasks = new Map();
|
|
93
|
+
/** Ended tasks whose final snapshot was sent successfully: never shown again. */
|
|
94
|
+
published = new Set();
|
|
95
|
+
/** Ambient task ids: never shown, and their later messages are not news. */
|
|
96
|
+
ambient = new Set();
|
|
97
|
+
/** task id → its current life; bumped on every start. */
|
|
98
|
+
lives = new Map();
|
|
99
|
+
/** task id → the life the latest snapshot carried as ended. */
|
|
100
|
+
snapshotLives = new Map();
|
|
101
|
+
/** task id → time after which a missing bookend means `interrupted`. */
|
|
102
|
+
deadlines = new Map();
|
|
103
|
+
/** Foreground shell tasks not published yet: see `promoteDue`. */
|
|
104
|
+
held = new Map();
|
|
105
|
+
/** Shell task ids labelled by their tool call: a patch never relabels them. */
|
|
106
|
+
toolDescribed = new Set();
|
|
107
|
+
constructor(clock, log = {}, sessionId = "") {
|
|
108
|
+
this.clock = clock;
|
|
109
|
+
this.log = log;
|
|
110
|
+
this.sessionId = sessionId;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* `toolDescription` is the spawning tool call's own `description` input. A
|
|
114
|
+
* shell task carries it when it is non-empty; otherwise the SDK's, which
|
|
115
|
+
* for a shell is the command text. Other task types keep the SDK's.
|
|
116
|
+
*/
|
|
117
|
+
onStarted(msg, toolDescription) {
|
|
118
|
+
if (msg.ambient) {
|
|
119
|
+
this.ambient.add(msg.task_id);
|
|
120
|
+
return "none";
|
|
121
|
+
}
|
|
122
|
+
if (this.tasks.get(msg.task_id)?.status === "running" || this.held.has(msg.task_id)) {
|
|
123
|
+
return "none";
|
|
124
|
+
}
|
|
125
|
+
const shell = msg.task_type === "local_bash";
|
|
126
|
+
const fromTool = shell && typeof toolDescription === "string" && toolDescription.trim() !== "";
|
|
127
|
+
if (fromTool)
|
|
128
|
+
this.toolDescribed.add(msg.task_id);
|
|
129
|
+
else
|
|
130
|
+
this.toolDescribed.delete(msg.task_id);
|
|
131
|
+
const entry = {
|
|
132
|
+
id: msg.task_id,
|
|
133
|
+
toolCallId: msg.tool_use_id ?? null,
|
|
134
|
+
type: normaliseType(msg.task_type),
|
|
135
|
+
description: fromTool ? toolDescription : msg.description,
|
|
136
|
+
background: msg.is_backgrounded ?? false,
|
|
137
|
+
depth: msg.spawn_depth ?? 0,
|
|
138
|
+
status: "running",
|
|
139
|
+
startedAt: this.clock(),
|
|
140
|
+
endedAt: null,
|
|
141
|
+
usage: null,
|
|
142
|
+
lastTool: null,
|
|
143
|
+
summary: null,
|
|
144
|
+
};
|
|
145
|
+
if (shell && msg.is_backgrounded !== true) {
|
|
146
|
+
this.held.set(msg.task_id, entry);
|
|
147
|
+
return "held";
|
|
148
|
+
}
|
|
149
|
+
this.admit(entry);
|
|
150
|
+
return "progress";
|
|
151
|
+
}
|
|
152
|
+
/** Publishes every held shell task whose hold has run out. */
|
|
153
|
+
promoteDue() {
|
|
154
|
+
const now = this.clock();
|
|
155
|
+
let change = "none";
|
|
156
|
+
for (const [id, entry] of this.held) {
|
|
157
|
+
if (entry.startedAt + FOREGROUND_SHELL_HOLD_MS > now)
|
|
158
|
+
continue;
|
|
159
|
+
this.held.delete(id);
|
|
160
|
+
this.admit(entry);
|
|
161
|
+
change = "progress";
|
|
162
|
+
}
|
|
163
|
+
return change;
|
|
164
|
+
}
|
|
165
|
+
/** When the earliest held shell task is due for promotion; null if none is held. */
|
|
166
|
+
nextHoldDue() {
|
|
167
|
+
let next = null;
|
|
168
|
+
for (const entry of this.held.values()) {
|
|
169
|
+
const due = entry.startedAt + FOREGROUND_SHELL_HOLD_MS;
|
|
170
|
+
if (next === null || due < next)
|
|
171
|
+
next = due;
|
|
172
|
+
}
|
|
173
|
+
return next;
|
|
174
|
+
}
|
|
175
|
+
onProgress(msg) {
|
|
176
|
+
const heldEntry = this.held.get(msg.task_id);
|
|
177
|
+
const task = heldEntry ?? this.running(msg.task_id, "task_progress");
|
|
178
|
+
if (!task)
|
|
179
|
+
return "none";
|
|
180
|
+
task.usage = usageOf(msg.usage);
|
|
181
|
+
task.lastTool = msg.last_tool_name ?? task.lastTool;
|
|
182
|
+
task.summary = msg.summary ?? task.summary;
|
|
183
|
+
// Kept for the promotion, but nobody has been shown the task yet.
|
|
184
|
+
return heldEntry ? "none" : "progress";
|
|
185
|
+
}
|
|
186
|
+
onUpdated(msg) {
|
|
187
|
+
const heldEntry = this.held.get(msg.task_id);
|
|
188
|
+
if (heldEntry) {
|
|
189
|
+
if (msg.patch.is_backgrounded === true) {
|
|
190
|
+
// Moved to the background: published at once, then patched as usual.
|
|
191
|
+
this.held.delete(msg.task_id);
|
|
192
|
+
this.admit(heldEntry);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
if (normaliseStatus(msg.patch.status)) {
|
|
196
|
+
this.held.delete(msg.task_id);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
this.patch(heldEntry, msg.patch);
|
|
200
|
+
}
|
|
201
|
+
return "none";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const task = this.running(msg.task_id, "task_updated");
|
|
205
|
+
if (!task)
|
|
206
|
+
return "none";
|
|
207
|
+
const ending = normaliseStatus(msg.patch.status);
|
|
208
|
+
this.patch(task, msg.patch);
|
|
209
|
+
if (ending) {
|
|
210
|
+
this.end(task, ending);
|
|
211
|
+
return "terminal";
|
|
212
|
+
}
|
|
213
|
+
return "progress";
|
|
214
|
+
}
|
|
215
|
+
onNotification(msg) {
|
|
216
|
+
// A held shell task that ends was never shown: its ending is not news.
|
|
217
|
+
if (this.held.delete(msg.task_id))
|
|
218
|
+
return "none";
|
|
219
|
+
const task = this.running(msg.task_id, "task_notification");
|
|
220
|
+
if (!task)
|
|
221
|
+
return "none";
|
|
222
|
+
const ending = normaliseStatus(msg.status, msg.reason) ?? "completed";
|
|
223
|
+
if (msg.summary)
|
|
224
|
+
task.summary = msg.summary;
|
|
225
|
+
const usage = usageOf(msg.usage);
|
|
226
|
+
if (usage)
|
|
227
|
+
task.usage = usage;
|
|
228
|
+
this.end(task, ending);
|
|
229
|
+
return "terminal";
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The SDK's level: every live background task. It never adds or ends a task
|
|
233
|
+
* by itself — it may precede the `task_started` that names a task, or the
|
|
234
|
+
* bookend that ends one. A running background task the level omits gets a
|
|
235
|
+
* reconcile deadline; a level that lists it again clears it.
|
|
236
|
+
*/
|
|
237
|
+
onLevel(level) {
|
|
238
|
+
const listed = new Set(level.map((t) => t.task_id));
|
|
239
|
+
const now = this.clock();
|
|
240
|
+
for (const task of this.tasks.values()) {
|
|
241
|
+
if (task.status !== "running" || !task.background)
|
|
242
|
+
continue;
|
|
243
|
+
if (listed.has(task.id))
|
|
244
|
+
this.deadlines.delete(task.id);
|
|
245
|
+
else if (!this.deadlines.has(task.id))
|
|
246
|
+
this.deadlines.set(task.id, now + RECONCILE_GRACE_MS);
|
|
247
|
+
}
|
|
248
|
+
return "none";
|
|
249
|
+
}
|
|
250
|
+
nextReconcileAt() {
|
|
251
|
+
let next = null;
|
|
252
|
+
for (const at of this.deadlines.values())
|
|
253
|
+
if (next === null || at < next)
|
|
254
|
+
next = at;
|
|
255
|
+
return next;
|
|
256
|
+
}
|
|
257
|
+
/** Ends as `interrupted` every task whose deadline passed with no bookend. */
|
|
258
|
+
reconcile(now) {
|
|
259
|
+
let change = "none";
|
|
260
|
+
for (const [id, at] of this.deadlines) {
|
|
261
|
+
if (at > now)
|
|
262
|
+
continue;
|
|
263
|
+
const task = this.tasks.get(id);
|
|
264
|
+
if (task?.status === "running") {
|
|
265
|
+
this.log.warn?.(`[tasks/reconcile] sessionId=${this.sessionId} taskId=${id} outcome=interrupted reason=missing_from_level`);
|
|
266
|
+
this.end(task, "interrupted", now);
|
|
267
|
+
change = "terminal";
|
|
268
|
+
}
|
|
269
|
+
this.deadlines.delete(id);
|
|
270
|
+
}
|
|
271
|
+
return change;
|
|
272
|
+
}
|
|
273
|
+
/** The CLI process restarted: whatever was running did not survive it. A
|
|
274
|
+
* held shell task was never shown, so it is dropped rather than ended. */
|
|
275
|
+
onProcessRestart() {
|
|
276
|
+
this.held.clear();
|
|
277
|
+
let change = "none";
|
|
278
|
+
for (const task of this.tasks.values()) {
|
|
279
|
+
if (task.status !== "running")
|
|
280
|
+
continue;
|
|
281
|
+
this.end(task, "interrupted");
|
|
282
|
+
change = "terminal";
|
|
283
|
+
}
|
|
284
|
+
return change;
|
|
285
|
+
}
|
|
286
|
+
/** True when the feed holds `taskId` as running — the stop request's gate. */
|
|
287
|
+
isRunning(taskId) {
|
|
288
|
+
return this.tasks.get(taskId)?.status === "running";
|
|
289
|
+
}
|
|
290
|
+
/** Running tasks plus ended tasks not yet published, oldest start first.
|
|
291
|
+
* Marks nothing: see `markPublished`. */
|
|
292
|
+
snapshot() {
|
|
293
|
+
const tasks = [...this.tasks.values()]
|
|
294
|
+
.filter((t) => t.status === "running" || !this.published.has(t.id))
|
|
295
|
+
.sort((a, b) => a.startedAt - b.startedAt)
|
|
296
|
+
.map((t) => ({ ...t, usage: t.usage ? { ...t.usage } : null }));
|
|
297
|
+
const endedIds = tasks.filter((t) => t.status !== "running").map((t) => t.id);
|
|
298
|
+
for (const id of endedIds)
|
|
299
|
+
this.snapshotLives.set(id, this.lives.get(id) ?? 0);
|
|
300
|
+
return { snapshot: { tasks }, endedIds };
|
|
301
|
+
}
|
|
302
|
+
/** Called after the send carrying these endings succeeded. Their entries are
|
|
303
|
+
* dropped; the ids are remembered so a late bookend cannot resurrect them. */
|
|
304
|
+
markPublished(endedIds) {
|
|
305
|
+
for (const id of endedIds) {
|
|
306
|
+
// Only the life the snapshot carried: a task that started again while
|
|
307
|
+
// the send was in flight is a new life the client has not seen end.
|
|
308
|
+
if (this.snapshotLives.get(id) !== this.lives.get(id))
|
|
309
|
+
continue;
|
|
310
|
+
if (this.tasks.get(id)?.status === "running")
|
|
311
|
+
continue;
|
|
312
|
+
this.snapshotLives.delete(id);
|
|
313
|
+
this.published.add(id);
|
|
314
|
+
this.tasks.delete(id);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
running(taskId, kind) {
|
|
318
|
+
const task = this.tasks.get(taskId);
|
|
319
|
+
if (task?.status === "running")
|
|
320
|
+
return task;
|
|
321
|
+
if (!task && !this.published.has(taskId) && !this.ambient.has(taskId)) {
|
|
322
|
+
this.log.debug?.(`[tasks/feed] sessionId=${this.sessionId} taskId=${taskId} ignored=${kind}`);
|
|
323
|
+
}
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
/** Puts a running entry into the table. A known id starting again is a new
|
|
327
|
+
* life of the same task — a resumed subagent is re-registered under its
|
|
328
|
+
* agent id. It replaces the ended entry, published or not: the running
|
|
329
|
+
* state supersedes an ending the client has not seen yet, and a published
|
|
330
|
+
* id must be shown again. */
|
|
331
|
+
admit(entry) {
|
|
332
|
+
this.published.delete(entry.id);
|
|
333
|
+
this.lives.set(entry.id, (this.lives.get(entry.id) ?? 0) + 1);
|
|
334
|
+
this.tasks.set(entry.id, entry);
|
|
335
|
+
}
|
|
336
|
+
/** Applies a `task_updated` patch's label and placement; a label taken from
|
|
337
|
+
* the tool call is never replaced. */
|
|
338
|
+
patch(task, patch) {
|
|
339
|
+
if (patch.description !== undefined && !this.toolDescribed.has(task.id)) {
|
|
340
|
+
task.description = patch.description;
|
|
341
|
+
}
|
|
342
|
+
if (patch.is_backgrounded !== undefined)
|
|
343
|
+
task.background = patch.is_backgrounded;
|
|
344
|
+
}
|
|
345
|
+
end(task, status, at = this.clock()) {
|
|
346
|
+
task.status = status;
|
|
347
|
+
task.endedAt = at;
|
|
348
|
+
this.deadlines.delete(task.id);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const realTimers = {
|
|
352
|
+
now: () => Date.now(),
|
|
353
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
354
|
+
clearTimeout: (handle) => clearTimeout(handle),
|
|
355
|
+
};
|
|
356
|
+
export class TaskFeedPublisher {
|
|
357
|
+
feed;
|
|
358
|
+
send;
|
|
359
|
+
timers;
|
|
360
|
+
onSendError;
|
|
361
|
+
lastSendAt = Number.NEGATIVE_INFINITY;
|
|
362
|
+
/** One send at a time: a snapshot taken while another is in flight would
|
|
363
|
+
* carry that one's endings again, and both would succeed. */
|
|
364
|
+
inFlight = false;
|
|
365
|
+
/** A publish requested while a send was in flight; `"terminal"` skips the
|
|
366
|
+
* throttle when it runs. */
|
|
367
|
+
queued = "none";
|
|
368
|
+
/** The trailing publish or the retry after a failed send — never both. */
|
|
369
|
+
pending = null;
|
|
370
|
+
reconcileTimer = null;
|
|
371
|
+
reconcileAt = null;
|
|
372
|
+
/** Promotes held foreground shells: armed at the feed's `nextHoldDue()`. */
|
|
373
|
+
holdTimer = null;
|
|
374
|
+
holdAt = null;
|
|
375
|
+
closed = false;
|
|
376
|
+
/** Set by `dispose({ flush: true })`: a publish queued behind an in-flight
|
|
377
|
+
* send still goes out once — a retirement's `interrupted` endings. */
|
|
378
|
+
flushOnSettle = false;
|
|
379
|
+
constructor(feed, send, timers = realTimers, onSendError = () => { }) {
|
|
380
|
+
this.feed = feed;
|
|
381
|
+
this.send = send;
|
|
382
|
+
this.timers = timers;
|
|
383
|
+
this.onSendError = onSendError;
|
|
384
|
+
}
|
|
385
|
+
notify(change) {
|
|
386
|
+
if (this.closed)
|
|
387
|
+
return;
|
|
388
|
+
if (change === "terminal") {
|
|
389
|
+
this.publishNow();
|
|
390
|
+
}
|
|
391
|
+
else if (change === "progress") {
|
|
392
|
+
const wait = this.lastSendAt + PUBLISH_INTERVAL_MS - this.timers.now();
|
|
393
|
+
if (wait <= 0)
|
|
394
|
+
this.publishNow();
|
|
395
|
+
else if (this.pending === null)
|
|
396
|
+
this.arm(wait);
|
|
397
|
+
}
|
|
398
|
+
else if (change === "held") {
|
|
399
|
+
this.armHold();
|
|
400
|
+
}
|
|
401
|
+
this.armReconcile();
|
|
402
|
+
}
|
|
403
|
+
dispose(options = {}) {
|
|
404
|
+
this.closed = true;
|
|
405
|
+
this.flushOnSettle = options.flush ?? false;
|
|
406
|
+
this.cancelPending();
|
|
407
|
+
if (this.reconcileTimer !== null)
|
|
408
|
+
this.timers.clearTimeout(this.reconcileTimer);
|
|
409
|
+
this.reconcileTimer = null;
|
|
410
|
+
this.reconcileAt = null;
|
|
411
|
+
if (this.holdTimer !== null)
|
|
412
|
+
this.timers.clearTimeout(this.holdTimer);
|
|
413
|
+
this.holdTimer = null;
|
|
414
|
+
this.holdAt = null;
|
|
415
|
+
}
|
|
416
|
+
publishNow() {
|
|
417
|
+
this.cancelPending();
|
|
418
|
+
if (this.inFlight) {
|
|
419
|
+
this.queued = "terminal";
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
this.inFlight = true;
|
|
423
|
+
this.lastSendAt = this.timers.now();
|
|
424
|
+
const { snapshot, endedIds } = this.feed.snapshot();
|
|
425
|
+
// The SDK message loop calls notify; nothing here may throw into it — not
|
|
426
|
+
// a synchronous throw from `send`, and not a throwing error handler.
|
|
427
|
+
void Promise.resolve()
|
|
428
|
+
.then(() => this.send(snapshot))
|
|
429
|
+
.then(() => {
|
|
430
|
+
this.feed.markPublished(endedIds);
|
|
431
|
+
this.settle();
|
|
432
|
+
}, (error) => {
|
|
433
|
+
try {
|
|
434
|
+
this.onSendError(error);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
// The retry below matters more than the log line.
|
|
438
|
+
}
|
|
439
|
+
this.settle();
|
|
440
|
+
if (!this.closed && !this.inFlight && this.pending === null) {
|
|
441
|
+
this.arm(PUBLISH_INTERVAL_MS);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
settle() {
|
|
446
|
+
this.inFlight = false;
|
|
447
|
+
const queued = this.queued;
|
|
448
|
+
this.queued = "none";
|
|
449
|
+
if (queued === "none")
|
|
450
|
+
return;
|
|
451
|
+
if (!this.closed) {
|
|
452
|
+
this.notify(queued);
|
|
453
|
+
}
|
|
454
|
+
else if (this.flushOnSettle) {
|
|
455
|
+
// One last send, no retry: the publisher is closed.
|
|
456
|
+
this.flushOnSettle = false;
|
|
457
|
+
void Promise.resolve()
|
|
458
|
+
.then(() => this.send(this.feed.snapshot().snapshot))
|
|
459
|
+
.catch((error) => {
|
|
460
|
+
try {
|
|
461
|
+
this.onSendError(error);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// Nothing left to arm.
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
arm(ms) {
|
|
470
|
+
this.pending = this.timers.setTimeout(() => {
|
|
471
|
+
this.pending = null;
|
|
472
|
+
if (!this.closed)
|
|
473
|
+
this.publishNow();
|
|
474
|
+
}, ms);
|
|
475
|
+
}
|
|
476
|
+
cancelPending() {
|
|
477
|
+
if (this.pending !== null)
|
|
478
|
+
this.timers.clearTimeout(this.pending);
|
|
479
|
+
this.pending = null;
|
|
480
|
+
}
|
|
481
|
+
/** One timer for every held shell, at the earliest due time. A held shell
|
|
482
|
+
* that ended early leaves the timer armed: it fires, promotes nothing, and
|
|
483
|
+
* re-arms for the next one still held. */
|
|
484
|
+
armHold() {
|
|
485
|
+
const at = this.feed.nextHoldDue();
|
|
486
|
+
if (at === null)
|
|
487
|
+
return;
|
|
488
|
+
if (this.holdAt !== null && this.holdAt <= at)
|
|
489
|
+
return;
|
|
490
|
+
if (this.holdTimer !== null)
|
|
491
|
+
this.timers.clearTimeout(this.holdTimer);
|
|
492
|
+
this.holdAt = at;
|
|
493
|
+
this.holdTimer = this.timers.setTimeout(() => {
|
|
494
|
+
this.holdTimer = null;
|
|
495
|
+
this.holdAt = null;
|
|
496
|
+
if (this.closed)
|
|
497
|
+
return;
|
|
498
|
+
// Outside the SDK loop nobody handles an error: a failed send is
|
|
499
|
+
// caught in `publishNow`, and anything else thrown is reported here.
|
|
500
|
+
try {
|
|
501
|
+
this.notify(this.feed.promoteDue());
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
try {
|
|
505
|
+
this.onSendError(error);
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
// Re-arming matters more than the log line.
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (!this.closed)
|
|
512
|
+
this.armHold();
|
|
513
|
+
}, Math.max(0, at - this.timers.now()));
|
|
514
|
+
}
|
|
515
|
+
armReconcile() {
|
|
516
|
+
const at = this.feed.nextReconcileAt();
|
|
517
|
+
if (at === null || at === this.reconcileAt)
|
|
518
|
+
return;
|
|
519
|
+
if (this.reconcileTimer !== null)
|
|
520
|
+
this.timers.clearTimeout(this.reconcileTimer);
|
|
521
|
+
this.reconcileAt = at;
|
|
522
|
+
this.reconcileTimer = this.timers.setTimeout(() => {
|
|
523
|
+
this.reconcileTimer = null;
|
|
524
|
+
this.reconcileAt = null;
|
|
525
|
+
if (this.closed)
|
|
526
|
+
return;
|
|
527
|
+
this.notify(this.feed.reconcile(this.timers.now()));
|
|
528
|
+
}, Math.max(0, at - this.timers.now()));
|
|
529
|
+
}
|
|
530
|
+
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.25.0",
|
|
7
7
|
"description": "Claude Code in Zed and other ACP clients with the VS Code extension's UX — multi-select questions, refusal consent, live quota windows, background tasks, Ultracode effort and session notices. Fork of claude-agent-acp.",
|
|
8
8
|
"main": "dist/lib.js",
|
|
9
9
|
"types": "dist/lib.d.ts",
|