Build in parallel with collaborating Claude Code agents and deliver their changes to your project.

<a href="https://yuzhenmao.github.io/DeLM/"> <img src="docs/assets/delm-wordmark.svg" alt="DeLM" width="240"> </a>
<h1 align="center">DeLM for Codex and Claude Code</h1>
<a href="https://arxiv.org/abs/2606.10662"><img src="docs/assets/paper.svg" alt="Read the paper on arXiv" height="28"></a> <a href="https://yuzhenmao.github.io/DeLM/"><img src="docs/assets/website.svg" alt="Visit the project website" height="28"></a> <a href="https://discord.com/invite/EuQyJPJzBt"><img src="docs/assets/discord.svg" alt="Join the DeLM community on Discord" height="28"></a>
<a href="#install-on-macos">Install</a> · <a href="#run-a-task">Usage</a> · <a href="docs/support.md">Support</a> · <a href="CONTRIBUTING.md">Contributing</a>
DeLM lets agents work in parallel in your existing Codex or Claude Code workflow. A shared task queue coordinates the work, while shared context lets agents exchange findings and reuse each other's code. Their contributions come together in your project.
DeLM uses your existing host account and starts agents only when you ask.
Install either or both plugins with one command:
npx --yes delm-agent@latest install
Requires macOS, Node.js 22 or later, Git, and the CLI for your chosen host with its login completed. Claude Code requires version 2.1.289 or later.
The installer detects your installed host. If both Codex and Claude Code are available, choose Codex, Claude Code, or Both. Explicit host flags are available for scripted installation. Each plugin uses its host's native plugin manager.
| Host | After installation |
|---|---|
| Codex | Restart, open /hooks, and review and trust the DeLM hooks. Restart once more to load them. |
| Claude Code | Restart to load DeLM. Review any trust or permission prompts Claude presents. |
See support for updates, removal, and troubleshooting, or the release guide for publication requirements.
Open your host in the project you want to change, then invoke DeLM with a request:
| Host | Start a run |
|---|---|
| Codex | $delm:run <your task> |
| Claude Code | /delm:run <your task> |
For example, in Claude Code:
/delm:run Build a task board with drag-and-drop columns, local persistence,
and keyboard controls. Include a README and test the main interactions.
Use $delm:run for the same request in Codex. Send clarifications in the same conversation while the agents work.
In Claude Code, a live board opens automatically in the same terminal. It shows the agents, task queue, and shared context while you keep using the normal prompt. Click a row for details. Hide board leaves work running; /delm-status reopens it without asking the model for a summary. Use /delm-stop to stop the run and save unfinished changes. If a run needs recovery, normal Claude conversation remains available and the board shows the next action. See run control and recovery for details.
Include images and file mentions when starting a Claude run. During a run, send text or paste the relevant file content. Claude's current native API cannot forward new media attachments to existing agents; DeLM explains this before accepting an unsupported update. See Claude input support for details.
The current version runs two agents in private project copies. They share contributions and divide useful checks, so a recorded check can be reused when it still applies to the result.
The result is delivered to your original project. DeLM applies source changes and requested artifacts, preserves your Git index, merges compatible edits, and retains conflicts for recovery. Saved partial changes can be exported into a new folder for review. Temporary worker directories are removed after safe delivery or recovery. When the delivered project needs dependency setup or a focused check, the parent completes it before reporting the result ready.
Choose one project smaller than 10 GB, including ignored files and Git history. DeLM initializes Git in that folder if needed without creating a commit. Runs have a 30-minute default allowance.
Claude's native forks inherit the current conversation, model, system prompt, and available tools. Coordination tools pass through Claude's normal permission checks.
Codex workers preserve saved skills, plugins, hooks, MCP configuration, and permissions. Codex's current fork API does not expose all parent-process CLI overrides, so exact live-session parity is still a limitation. The support guide explains what is inherited and how to check your setup.
| Guide | Contents |
|---|---|
| Support | Requirements, permissions, timing reports, updates, and recovery |
| Architecture | Host adapters, shared coordination, and project delivery |
| Contributing | Development setup and verification |
| Release guide | Build qualification and distribution |
| Claude integration | Native integration design and validation |
DeLM builds on Decentralized Multi-Agent Systems with Shared Context. See the paper, project website, and research code for the method, evaluations, and agent trajectories.
Development of the Codex and Claude Code plugins was led by Jerry Gu.
Thanks to all contributors:
<a href="https://github.com/jerry2247"><img src="https://avatars.githubusercontent.com/u/88354083?v=4&s=128" width="64" height="64" alt="Jerry Gu" title="Jerry Gu"></a> <a href="https://github.com/ZachDeLong"><img src="https://avatars.githubusercontent.com/u/242496128?v=4&s=128" width="64" height="64" alt="ZachDeLong" title="ZachDeLong"></a>
DeLM is licensed under MIT. Third-party components retain their own licenses; see NOTICE.
hooks/delm.js 1065 lines1import {
2 TOOL_PREFIX, commandOutcome, completeState, decodeLines,
3 followupText, nativeLaunchContext, parseReply, toolArguments, validateReady,
4} from './protocol.js';
5import {registerBoard, observeBoard} from './board-view.js';
6
7const runs = new Map();
8const restores = new Map();
9const starts = new Set();
10const generations = new Map();
11const restoreAttempts = new Set();
12const peers = new Map();
13const STORE_PREFIX = 'native-run:';
14function ownsConversation(run) {
15 return Boolean(run && !run.finished && !run.ending && run.phase === 'working'
16 && !run.failure && !run.stopping && !run.finalization && !run.cancelRequested);
17}
18function updateBoard($, run) {
19 run.conversationAvailable = !ownsConversation(run);
20 try { observeBoard(run, () => retryFinish($, run.session)); } catch { /* Presentation cannot fail a control operation. */ }
21}
22
23function snapshot(run) {
24 return {
25 ready: run.ready, session: run.session, revision: run.revision,
26 agents: run.agents, descendants: run.descendants, background: run.background,
27 finished: run.finished, final: run.final, ending: Boolean(run.ending), failure: run.failure || null,
28 phase: run.phase, finalization: run.finalization, canRetryFinish: Boolean(run.canRetryFinish),
29 controls: run.controls || [],
30 completionPending: run.completionPending || {},
31 cancelRequested: Boolean(run.cancelRequested),
32 conversationAvailable: !ownsConversation(run),
33 };
34}
35
36async function persist($, run) {
37 const value = snapshot(run);
38 // Preserve write ordering without permanently inheriting a transient failure.
39 // Each caller still observes its own write failure before admitting more work.
40 run.writes = run.writes.catch(() => {}).then(() => $.store.set(STORE_PREFIX + run.session, value));
41 await run.writes;
42 updateBoard($, run);
43}
44
45async function currentRun($, restoreInterrupted = true) {
46 const session = await $.session.id();
47 const known = runs.get(session);
48 if (!restoreInterrupted) return known || null;
49 if (restores.has(session)) return restores.get(session);
50 if (known && (!known.ending || known.finished)) return known;
51 const restoring = restore($, session).finally(() => {
52 if (restores.get(session) === restoring) restores.delete(session);
53 });
54 restores.set(session, restoring);
55 return restoring;
56}
57
58function restoreInBackground($) {
59 void (async () => {
60 const session = await $.session.id();
61 if (restoreAttempts.has(session)) return;
62 restoreAttempts.add(session);
63 try { await currentRun($); }
64 catch (error) {
65 $.ui.log('DeLM recovery needs attention: ' + String(error.message || error)
66 + '. You can continue chatting normally; no unfinished workspace was removed.');
67 }
68 })().catch(() => {});
69}
70
71async function eventRun($, agentId) {
72 if (agentId) {
73 for (const run of runs.values()) if (owned(run, agentId)) return run;
74 }
75 return currentRun($, false);
76}
77
78async function restore($, session) {
79 const stored = await $.store.get(STORE_PREFIX + session);
80 if (!stored || await $.session.id() !== session) return null;
81 const known = runs.get(session);
82 if (known && (!known.ending || known.finished)) return known;
83 if (stored.session !== session) throw new Error('DeLM found a run record for another conversation.');
84 if (stored.finished) {
85 const run = {...stored, writes: Promise.resolve()};
86 runs.set(session, run);
87 return run;
88 }
89 try { return await recover($, stored); }
90 catch (error) {
91 const run = runs.get(session);
92 if (run) await reportFailure($, run, error);
93 else $.ui.log('DeLM could not restore this conversation: ' + String(error.message || error));
94 return run || null;
95 }
96}
97
98async function current($, run) {
99 return !run.ending && runs.get(run.session) === run && await $.session.id() === run.session;
100}
101
102// A hook running for a forked peer has that peer's private workspace as its
103// session directory. Helper processes must never inherit it: a helper still
104// alive there vetoes workspace cleanup when the run stops or finishes.
105function helperDirectory(run) {
106 const executable = run.ready.executable;
107 return executable.slice(0, executable.lastIndexOf('/')) || '/';
108}
109
110async function request($, run, op, fields = {}, timeoutMs = 120000) {
111 const result = await $.process.run(
112 [run.ready.executable, 'claude', 'request', '--socket', run.ready.socket],
113 {stdin: JSON.stringify({token: run.ready.token, op, ...fields}) + '\n', timeoutMs, cwd: helperDirectory(run)},
114 );
115 if (result.exitCode !== 0 || result.isStdoutTruncated || result.isStderrTruncated) {
116 throw new Error(result.stderr.trim() || 'DeLM native bridge did not complete its request.');
117 }
118 const reply = parseReply(result.stdout);
119 if (Number.isSafeInteger(reply?.revision)) run.revision = Math.max(run.revision, reply.revision);
120 if (Number.isSafeInteger(reply?.run?.revision)) run.revision = Math.max(run.revision, reply.run.revision);
121 // Actions arrive once on the daemon stream; the RPC copy is not replayed.
122 return reply;
123}
124
125async function reportFailure($, run, error) {
126 // The runtime's final result settles the run. Bookkeeping that loses the
127 // race with the bridge's exit after that result, such as a stopped peer's
128 // turn end, cannot fail or reopen it.
129 if (run.finished) return;
130 run.failure = String(error?.message || error);
131 run.phase = 'recovery_required';
132 run.canRetryFinish = run.finalization?.intent === 'deliver' && !run.finished && !run.cancelRequested;
133 for (const gate of Object.values(run.deliveries || {})) gate.reject(new Error(run.failure));
134 run.deliveries = {};
135 if (await current($, run)) $.ui.log('DeLM needs attention: ' + run.failure);
136 try { await persist($, run); }
137 catch { updateBoard($, run); }
138}
139
140async function drain($, run, stream, initial) {
141 let buffer = initial;
142 try {
143 for await (const chunk of stream) {
144 if (chunk.stream === 'stderr') {
145 run.stderr = (run.stderr + chunk.text).slice(-8192);
146 continue;
147 }
148 const decoded = decodeLines(buffer, chunk.text);
149 buffer = decoded.rest;
150 for (const event of decoded.events) {
151 if (event.type === 'action') await actions($, run, event.result?.actions || []);
152 }
153 }
154 if (!run.finished && !run.ending) {
155 throw new Error(run.stderr.trim() || 'The native bridge stopped. Use /delm-stop to recover this run.');
156 }
157 } catch (error) {
158 if (!run.finished && !run.ending) await reportFailure($, run, error);
159 }
160}
161
162async function start($, task, session) {
163 const generation = generations.get(session) || 0;
164 if (await $.session.id() !== session) throw new Error('The conversation changed before preparation. Retry /delm:run here.');
165 const connection = await $.mcp.connect('delm');
166 if (!connection.isConnected) {
167 throw new Error('Claude could not connect DeLM\'s native tools: '
168 + (connection.message || connection.reason) + '. Reconnect DeLM in /mcp, then retry /delm:run.');
169 }
170 if (await $.session.id() !== session) throw new Error('The conversation changed before preparation. Retry /delm:run here.');
171 const project = await $.session.cwd();
172 const prompt = await $.fs.read($.plugin.root + '/hooks/worker.md');
173 let hostVersion = null;
174 try { hostVersion = (await $.session.version()).version; } catch { /* Optional diagnostic metadata. */ }
175 const stream = $.process.spawn({
176 argv: [$.plugin.root + '/bin/delm', 'claude', 'serve'],
177 input: JSON.stringify({project, session_id: session, task, host_version: hostVersion, package_root: $.plugin.root}) + '\n',
178 });
179 let buffer = '';
180 let stderr = '';
181 for (;;) {
182 const chunk = await stream.next();
183 if (chunk.done) throw new Error(stderr.trim() || 'DeLM could not prepare its native workspaces.');
184 if (chunk.value.stream === 'stderr') {
185 stderr = (stderr + chunk.value.text).slice(-8192);
186 continue;
187 }
188 const decoded = decodeLines(buffer, chunk.value.text);
189 buffer = decoded.rest;
190 if (!decoded.events.length) continue;
191 const ready = validateReady(decoded.events[0]);
192 if (ready.session_id !== session) throw new Error('DeLM returned workspaces for another conversation.');
193 const run = {
194 ready, session, prompt, task, revision: ready.revision, agents: {}, descendants: {},
195 background: {}, bindings: [], deliveries: {}, updating: null, launches: 0, awaitingLaunch: true,
196 launchTurn: null, finished: false, final: null, ending: false,
197 phase: 'working', finalization: null, canRetryFinish: false, settlement: null,
198 retryTimer: null, retryCount: 0, proofVersion: 0,
199 writes: Promise.resolve(), stopping: false,
200 stream, stderr, failure: null,
201 };
202 runs.set(session, run);
203 restores.delete(session);
204 await persist($, run);
205 if (await $.session.id() !== session || (generations.get(session) || 0) !== generation) {
206 run.ending = true;
207 await request($, run, 'cancel', {reason: 'Conversation changed during preparation'});
208 await persist($, run);
209 throw new Error('The conversation changed during preparation. Return to its conversation and use /delm-stop to recover.');
210 }
211 for (const event of decoded.events.slice(1)) {
212 if (event.type === 'action') await actions($, run, event.result?.actions || []);
213 }
214 void drain($, run, stream, buffer);
215 return run;
216 }
217}
218
219async function submitControl($, run, message, detail = {kind: 'final'}) {
220 if (!await current($, run) || (run.finished && !run.final)) return;
221 const control = {text: message, ...detail, admitted: false, turn: null};
222 run.controls ||= [];
223 run.controls.push(control);
224 if (run.controls.length > 32) run.controls.splice(0, run.controls.length - 32);
225 try {
226 await persist($, run);
227 await $.prompt.submit({text: message});
228 } catch (error) {
229 if (!run.finished) await reportFailure($, run, error);
230 else if (await current($, run)) {
231 $.ui.log('DeLM finished, but could not post its report: ' + String(error.message || error).replace(/\.$/, '')
232 + '. Open /delm-status for the outcome.');
233 }
234 }
235}
236
237function deliveryGate(run, id) {
238 if (!run.deliveries[id]) {
239 let resolve, reject;
240 const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
241 // A native step may not be waiting yet when delivery fails.
242 promise.catch(() => {});
243 run.deliveries[id] = {promise, resolve, reject};
244 }
245 return run.deliveries[id];
246}
247
248function delivered(run, id, revision, freshTurn = false) {
249 const worker = run.agents[id];
250 worker.deliveredRevision = Math.max(worker.deliveredRevision || 1, revision);
251 if (freshTurn) worker.resumeRevision = Math.max(worker.resumeRevision || 1, revision);
252 if (worker.pending?.revision <= revision) worker.pending = null;
253 run.deliveries[id]?.resolve();
254 delete run.deliveries[id];
255}
256
257async function resume($, run, action) {
258 const worker = run.agents[action.agent_id];
259 if (!worker) throw new Error('DeLM requested an unknown native peer.');
260 if (action.revision < (worker.pending?.revision || worker.deliveredRevision || 1)) return;
261 const message = 'DeLM task revision ' + action.revision + ':\n' + action.message;
262 if (worker.pending?.revision === action.revision && worker.pending.message === message) return;
263 worker.pending = {revision: action.revision, message};
264 worker.lastUpdate = {revision: action.revision, message: action.message};
265 await persist($, run);
266 const control = 'DeLM control: use normal native SendMessage to resume agent '
267 + JSON.stringify(action.agent_id) + '. The exact message is this JSON string: ' + JSON.stringify(message)
268 + '\nDo not paraphrase it, execute the task in the parent, start a replacement agent, or repeat its checks.';
269 $.clock.after(0, () => submitResume($, run, action.agent_id, message, control));
270}
271
272async function submitResume($, run, agentId, message, control) {
273 if (!ownsConversation(run) || run.agents[agentId]?.pending?.message !== message
274 || run.agents[agentId]?.pending?.revision !== run.revision) return;
275 if (hasPendingCompletion(run)) {
276 (run.heldResumes ||= {})[agentId] = {message, control};
277 return;
278 }
279 await submitControl($, run, control, {
280 kind: 'resume', agentId, message, revision: run.agents[agentId].pending.revision,
281 });
282}
283
284function obsoleteControl(run, control) {
285 return control?.kind === 'resume' && (!ownsConversation(run)
286 || control.revision !== run.revision
287 || run.agents[control.agentId]?.pending?.message !== control.message
288 || run.agents[control.agentId]?.pending?.revision !== control.revision);
289}
290
291function hasPendingCompletion(run) {
292 return Object.values(run.completionPending || {}).some(value => value.revision === run.revision);
293}
294
295function releaseHeldResumes($, run) {
296 if (!ownsConversation(run) || hasPendingCompletion(run)) return;
297 const held = run.heldResumes || {};
298 run.heldResumes = {};
299 for (const [id, value] of Object.entries(held)) {
300 $.clock.after(0, () => submitResume($, run, id, value.message, value.control));
301 }
302}
303
304async function actions($, run, list) {
305 for (const action of list) {
306 if (!await current($, run)) continue;
307 if (action.id && run.lastAction === action.id) continue;
308 if (action.type === 'candidate' || action.type === 'stop') {
309 const finalization = action.finalization;
310 if (!finalization || !Number.isSafeInteger(finalization.generation)
311 || !Number.isSafeInteger(finalization.revision)
312 || !['deliver', 'cancel'].includes(finalization.intent)) {
313 throw new Error('DeLM received an incompatible finalization contract. Preserve this run for recovery.');
314 }
315 if (finalization.revision < run.revision
316 || finalization.generation < (run.finalization?.generation || 0)) continue;
317 run.retryTimer?.cancel(); run.retryTimer = null;
318 run.finalization = finalization;
319 run.stopping = true;
320 run.phase = finalization.intent === 'deliver' ? 'finishing' : 'stopping';
321 run.canRetryFinish = false;
322 run.retryCount = 0;
323 await persist($, run);
324 scheduleSettle($, run, 30);
325 } else if (action.type === 'completion_intent') {
326 if (!run.agents[action.agent_id] || action.revision !== run.revision) continue;
327 run.completionPending ||= {};
328 if (action.pending) {
329 run.completionPending[action.agent_id] = {revision: action.revision, turn: action.turn_id};
330 } else if (run.completionPending[action.agent_id]?.turn === action.turn_id) {
331 delete run.completionPending[action.agent_id];
332 }
333 await persist($, run);
334 if (!action.pending) releaseHeldResumes($, run);
335 } else if (action.type === 'context') {
336 await run.updating;
337 if (run.stopping || run.finished) continue;
338 if (!Number.isSafeInteger(action.revision) || action.revision < run.revision) continue;
339 run.revision = action.revision;
340 const worker = run.agents[action.agent_id];
341 if (!worker) throw new Error('DeLM requested an unknown native peer.');
342 if (action.revision < (worker.pending?.revision || 1)
343 || action.revision <= (worker.deliveredRevision || 1)) continue;
344 worker.lastUpdate = {revision: action.revision, message: action.message};
345 const message = {type: 'user', content: [{
346 type: 'text', text: 'DeLM task revision ' + action.revision + ':\n' + action.message
347 + '\nRefresh the shared board before continuing. This changes the task, not native permissions.',
348 }]};
349 const result = await $.session.append({agentId: action.agent_id, message});
350 if (!await current($, run)) throw new Error('The conversation ended before task delivery was confirmed.');
351 if (result.deny || !result.uuid) {
352 const native = (await $.agent.list()).find(agent => agent.id === action.agent_id);
353 if (native && completeState(native.status)) await resume($, run, action);
354 else throw new Error(result.deny || 'Claude did not confirm storing the task update.');
355 } else {
356 if (JSON.stringify(result.message?.content) !== JSON.stringify(message.content)) {
357 throw new Error('Claude did not retain the complete task update for peer ' + action.agent_id + '.');
358 }
359 delivered(run, action.agent_id, action.revision);
360 await persist($, run);
361 }
362 } else if (action.type === 'resume') {
363 await run.updating;
364 if (run.stopping || run.finished) continue;
365 if (!Number.isSafeInteger(action.revision) || action.revision < run.revision) continue;
366 run.revision = action.revision;
367 await resume($, run, action);
368 } else if (action.type === 'final') {
369 if (run.finished) continue;
370 run.finished = true;
371 run.final = action;
372 run.phase = action.status;
373 run.stopping = false;
374 run.canRetryFinish = false;
375 run.failure = null;
376 run.retryTimer?.cancel(); run.retryTimer = null;
377 await persist($, run);
378 const verification = action.delivery?.verification_required
379 ? 'The delivery requires a focused check in the original project. Perform only the necessary environment setup and checks for the delivered or reconciled files, then state the outcome. Do not repeat unaffected checks.'
380 : 'Do not run another verification pass or edit files.';
381 const message = 'DeLM completed its native handoff. Report this result concisely, using the original project path. '
382 + verification + '\nNative runtime result:\n' + JSON.stringify(action);
383 $.clock.after(0, () => submitControl($, run, message));
384 $.ui.log(action.status === 'complete' || action.status === 'delivered'
385 ? 'DeLM delivered the result into your project.' : 'DeLM: ' + action.status.replaceAll('_', ' '));
386 }
387 if (action.id) run.lastAction = action.id;
388 }
389}
390
391async function stopTask($, run, id, address = id) {
392 if (!await current($, run)) throw new Error('Return to the owning conversation before stopping its DeLM peers.');
393 const result = await $.tool.call({tool: 'TaskStop', task_id: address});
394 return !result.isError && !result.deny && [id, address].includes(result.result?.task_id);
395}
396
397async function stopOwned($, run) {
398 if (!await current($, run)) throw new Error('Return to the owning conversation before recovering its DeLM peers.');
399 // Stop known shell children before parents. Stopping an agent first can
400 // cascade to its shell, retiring the native task before TaskStop can return
401 // that shell's acknowledgment.
402 for (const [id, task] of Object.entries(run.background)) {
403 if (task.stopped) continue;
404 const acknowledged = await stopTask($, run, id);
405 if (!acknowledged && !task.stopped) {
406 throw new Error('Native shutdown is not confirmed for background task ' + id + '. Its workspace is preserved.');
407 }
408 task.stopped = true;
409 }
410 let native = await $.agent.list();
411 // Descendant agent loops can themselves own children; stop deepest first.
412 const depth = id => {
413 let value = 0, parent = run.descendants[id]?.parent;
414 const seen = new Set([id]);
415 while (parent && !seen.has(parent)) { seen.add(parent); value++; parent = run.descendants[parent]?.parent; }
416 return value;
417 };
418 for (const [id, worker] of Object.entries({...run.agents, ...run.descendants}).sort(([a], [b]) => depth(b) - depth(a))) {
419 const observed = native.find(agent => agent.id === id);
420 if (observed && completeState(observed.status)) {
421 worker.status = observed.status;
422 } else if (observed && await stopTask($, run, id, observed.teammateId || id)) {
423 worker.status = 'killed';
424 } else if (!completeState(worker.status)) {
425 throw new Error('Native shutdown is not confirmed for agent ' + id + '. Its workspace is preserved.');
426 }
427 }
428 // TaskStop and turn.complete can precede the native task registry's final
429 // transition. Observe that transition rather than treating an acknowledgment
430 // as proof that the agent has already stopped.
431 for (let attempt = 0; attempt < 40; attempt++) {
432 native = await $.agent.list();
433 const pending = native.some(agent => owned(run, agent.id) && !completeState(agent.status));
434 if (!pending) break;
435 await $.clock.sleep(50);
436 }
437 for (const [id, worker] of Object.entries({...run.agents, ...run.descendants})) {
438 const observed = native.find(agent => agent.id === id);
439 if (observed && !completeState(observed.status)) {
440 throw new Error('Native agent ' + id + ' is still active. Its workspace is preserved.');
441 }
442 if (observed) worker.status = observed.status;
443 }
444 await persist($, run);
445 return Object.entries(run.agents).map(([id, worker]) => ({id, status: worker.status}));
446}
447
448function sameFinalization(run, fence) {
449 return !run.finished && run.finalization?.generation === fence?.generation
450 && run.finalization?.revision === fence?.revision && run.finalization?.intent === fence?.intent;
451}
452
453function scheduleSettle($, run, delay = 0) {
454 if (!run.finalization || run.finished || run.ending || run.retryTimer) return;
455 const fence = {...run.finalization};
456 run.retryTimer = $.clock.after(delay, async () => {
457 run.retryTimer = null;
458 if (sameFinalization(run, fence)) await settle($, run, fence);
459 });
460}
461
462async function settle($, run, fence = run.finalization) {
463 if (!fence || !sameFinalization(run, fence) || !await current($, run)) return;
464 if (run.settlement) { await run.settlement; return; }
465 const proofVersion = run.proofVersion || 0;
466 run.settlement = (async () => {
467 try {
468 // This RPC is the admission boundary. A newer update that won first
469 // invalidates this generation before any native task is stopped.
470 const result = await request($, run, 'begin_settle', {generation: fence.generation, revision: fence.revision});
471 if (!sameFinalization(run, fence) || !await current($, run)) return;
472 if (run.cancelRequested && result.finalization.intent !== 'cancel') {
473 const cancelled = await request($, run, 'cancel', {reason: 'User requested /delm-stop'});
474 await actions($, run, cancelled.actions || []);
475 return;
476 }
477 run.finalization = result.finalization;
478 run.canRetryFinish = false;
479 run.failure = null;
480 run.phase = fence.intent === 'deliver' ? 'finishing' : 'stopping';
481 await persist($, run);
482 const agents = await stopOwned($, run);
483 if (!sameFinalization(run, fence) || !await current($, run)) return;
484 const final = await request($, run, 'settle', {
485 generation: fence.generation, revision: fence.revision, agents, background_tasks_stopped: true,
486 });
487 // Recovery can reconnect to a live bridge whose original stream reader
488 // disappeared. Applying final actions is idempotent across both routes.
489 if (run.restored) await actions($, run, final.actions || []);
490 } catch (error) {
491 if (!sameFinalization(run, fence)) return;
492 try {
493 await request($, run, 'settlement_failed', {generation: fence.generation, revision: fence.revision});
494 } catch { /* A missing bridge retains its candidate for authenticated recovery. */ }
495 await reportFailure($, run, error);
496 }
497 })();
498 try { await run.settlement; }
499 finally {
500 run.settlement = null;
501 if (!run.finished && run.finalization) {
502 if (!sameFinalization(run, fence)) scheduleSettle($, run);
503 else if (run.failure && ((run.proofVersion || 0) > proofVersion || (run.retryCount || 0) < 2)) {
504 run.retryCount = (run.retryCount || 0) + 1;
505 scheduleSettle($, run, 250 * run.retryCount);
506 }
507 }
508 }
509}
510
511async function retryFinish($, session) {
512 const run = await currentRun($);
513 if (!run || run.session !== session || run.finished || !run.canRetryFinish
514 || run.cancelRequested || run.finalization?.intent !== 'deliver') throw new Error('No completed result is awaiting delivery in this conversation.');
515 run.retryTimer?.cancel(); run.retryTimer = null;
516 run.retryCount = 0;
517 if (run.settlement) await run.settlement;
518 if (!await current($, run) || run.finished || run.finalization?.intent !== 'deliver') return;
519 const recovered = await recover($, snapshot(run));
520 if (!recovered.finished && recovered.failure) throw new Error(recovered.failure);
521}
522
523async function recover($, stored, requestedIntent = null) {
524 const run = {
525 ...stored, ready: validateReady(stored.ready), writes: Promise.resolve(),
526 bindings: [], deliveries: {}, updating: null, launches: 2, awaitingLaunch: false, failure: null, stopping: true, ending: false,
527 phase: 'recovery_required', restored: true, settlement: null, retryTimer: null, retryCount: 0, proofVersion: 0,
528 };
529 run.cancelRequested = Boolean(stored.cancelRequested || requestedIntent === 'cancel');
530 runs.set(run.session, run);
531 if (run.cancelRequested) {
532 try { await persist($, run); }
533 catch { /* The authenticated runtime cancellation below is also durable. */ }
534 }
535 let live;
536 try { live = await request($, run, 'status', {}, 2000); } catch { /* An exited bridge uses durable recovery below. */ }
537 if (live?.run) {
538 run.finalization = live.run.finalization;
539 if (live.finished && live.final) { await actions($, run, [live.final]); return run; }
540 if (!run.finalization || (run.cancelRequested && run.finalization.intent !== 'cancel')) {
541 const stopped = await request($, run, 'cancel', {reason: run.cancelRequested ? 'User requested /delm-stop' : 'Conversation recovery requested'});
542 await actions($, run, stopped.actions || []);
543 }
544 await settle($, run);
545 return run;
546 }
547 const agents = await stopOwned($, run);
548 const result = await $.process.run([run.ready.executable, 'claude', 'recover', '--run-id', run.ready.run_id], {
549 stdin: JSON.stringify({token: run.ready.token, session_id: run.session, agents, background_tasks_stopped: true,
550 intent: run.cancelRequested ? 'cancel' : requestedIntent || run.finalization?.intent || 'auto'}) + '\n',
551 timeoutMs: 120000, cwd: helperDirectory(run),
552 });
553 if (result.exitCode !== 0 || result.isStdoutTruncated || result.isStderrTruncated) {
554 throw new Error(result.stderr.trim() || 'DeLM needs recovery before another run can start.');
555 }
556 const recovered = JSON.parse(result.stdout);
557 run.finished = true;
558 run.final = recovered;
559 run.phase = recovered.status;
560 run.canRetryFinish = false;
561 await persist($, run);
562 if (await current($, run)) $.ui.log('DeLM recovered the interrupted run. ' + (recovered.message || 'Saved work remains available in the run record.'));
563 return run;
564}
565
566function owned(run, id) {
567 return id && (run?.agents[id] || run?.descendants[id]);
568}
569
570function rememberPeer(run, agentId, slot) {
571 if (!peers.has(run.session)) peers.set(run.session, new Map());
572 peers.get(run.session).set(agentId, slot);
573}
574
575// Claude notifies the parent each time a forked peer stops, including when
576// DeLM stops it while settling. DeLM reports peer progress on its board and
577// the outcome in its handoff, so admitting the notification only starts a
578// parent turn that narrates stale peer status, even after delivery, and holds
579// DeLM's own controls behind that turn. A peer of an earlier run in this
580// conversation can still notify after a new run starts. Anything else in the
581// prompt, including another task's notification, keeps it for the parent.
582const NOTIFICATION = /<task-notification>[\s\S]*?<\/task-notification>/g;
583
584function peerNotification(run, session, e) {
585 const blocks = e.text.match(NOTIFICATION) || [];
586 if (!blocks.length || e.text.replace(NOTIFICATION, '').trim()) return null;
587 const slots = new Set();
588 for (const block of blocks) {
589 const id = /^<task-notification>\s*<task-id>([^<]+)<\/task-id>/.exec(block)?.[1].trim();
590 const slot = id && (run?.agents[id]?.slot || peers.get(session)?.get(id));
591 if (!slot) return null;
592 slots.add(slot);
593 }
594 const names = [...slots].sort((a, b) => a - b);
595 return 'DeLM handled ' + (names.length === 1 ? 'a status notice from peer ' + names[0]
596 : 'status notices from peers ' + names.join(' and ')) + ' itself; see /delm-status for progress.';
597}
598
599async function waitForOwnBinding(run, agentId) {
600 for (;;) {
601 const own = run.bindings.find(value => value.agentId === agentId);
602 if (own) { await own.promise; return; }
603 // A child can enter its first step before agent.spawn returns its identity.
604 // Wait only until that identity is known, never for the other peer's setup.
605 const pending = run.bindings.filter(value => !value.done);
606 if (!pending.length) return;
607 await Promise.race(pending.map(value => value.promise));
608 }
609}
610
611function* stoppedStep(event, answer) {
612 yield {kind: 'text', index: 0, text: answer};
613 yield {kind: 'stop', stopReason: 'end_turn', usage: null};
614 return {turnId: event.turnId, index: event.index, answer, toolUses: [], stopReason: 'end_turn', usage: null};
615}
616
617export function register(on) {
618 registerBoard(on);
619 on('session.start', async ($, e, next) => {
620 await $.command.register({name: 'delm-status', description: 'Show the DeLM board', immediate: true});
621 await $.command.register({name: 'delm-stop', description: 'Stop DeLM and save unfinished changes', immediate: true});
622 restoreInBackground($);
623 return next(e);
624 });
625
626 on('command.run', {command: 'delm:run'}, async ($, e, next) => {
627 const session = await $.session.id();
628 if (starts.has(session)) return {text: 'DeLM is preparing this conversation. Wait for its native peers to start.'};
629 if (!e.args.trim()) return {text: 'Use /delm:run followed by the task you want to complete.'};
630 starts.add(session);
631 try {
632 const known = await currentRun($);
633 if (known && !known.finished) return {text: ownsConversation(known)
634 ? 'DeLM is already working in this conversation. Send a follow-up, or use /delm-stop first.'
635 : 'The previous DeLM run needs finishing or recovery before another run can start. You can use Claude normally here. Open /delm-status for its recovery state, or use /delm-stop to save unfinished changes.'};
636 const run = await start($, e.args.trim(), session);
637 return next({...e, args: nativeLaunchContext(run.task, run.prompt)});
638 } catch (error) {
639 const run = runs.get(session);
640 if (run && !run.finished) await reportFailure($, run, error);
641 return {text: 'DeLM could not start: ' + String(error.message || error), exitCode: 1};
642 } finally { starts.delete(session); }
643 });
644
645 on('command.run', {command: 'delm-stop'}, async ($) => {
646 let active;
647 try {
648 active = await currentRun($, false);
649 if (!active) {
650 const session = await $.session.id();
651 const stored = await $.store.get(STORE_PREFIX + session);
652 if (stored && stored.session !== session) throw new Error('DeLM found a run record for another conversation.');
653 active = await currentRun($, false);
654 if (!active && stored && !stored.finished) {
655 const recovered = await recover($, {...stored, cancelRequested: true}, 'cancel');
656 return {text: recovered.finished ? 'DeLM stopped and recovered the interrupted run.'
657 : 'DeLM could not yet confirm shutdown. Its work is preserved, and you can continue chatting normally.'};
658 }
659 }
660 }
661 catch (error) { return {text: 'DeLM recovery needs attention: ' + String(error.message || error) + '. You can continue chatting normally.'}; }
662 if (!active || active.finished) return {text: 'No DeLM run is active in this session.'};
663 try {
664 active.cancelRequested = true;
665 active.stopping = true;
666 active.phase = 'stopping';
667 active.canRetryFinish = false;
668 active.retryTimer?.cancel(); active.retryTimer = null;
669 if (active.finalization) active.finalization = {...active.finalization, intent:'cancel'};
670 updateBoard($, active);
671 try { await persist($, active); }
672 catch { /* Still record the explicit stop in the authoritative runtime. */ }
673 const result = await request($, active, 'cancel', {reason: 'User requested /delm-stop'});
674 await actions($, active, result.actions || []);
675 scheduleSettle($, active);
676 return {text: 'Stopping DeLM and preserving unfinished work.'};
677 } catch (error) {
678 try {
679 const recovered = await recover($, snapshot(active), 'cancel');
680 return {text: recovered.finished ? 'DeLM stopped and recovered the interrupted run.'
681 : 'DeLM could not yet confirm shutdown. Its work is preserved, and you can continue chatting normally.'};
682 }
683 catch (recoveryError) { return {text: 'DeLM requires recovery: ' + String(recoveryError.message || recoveryError)}; }
684 }
685 });
686
687 on('turn.start', async ($, e, next) => {
688 const active = await currentRun($, false);
689 if (active) active.mainTurn = e.turnId;
690 const control = active?.controls?.find(value => value.admitted && !value.turn && value.text === e.text);
691 if (control) control.turn = e.turnId;
692 if (active?.awaitingLaunch) {
693 active.awaitingLaunch = false;
694 active.launchTurn = e.turnId;
695 }
696 return next(e);
697 });
698
699 on('agent.spawn', async ($, e, next) => {
700 const run = await eventRun($, e.parentAgentId);
701 if (run && ((!run.finished && !await current($, run))
702 || (owned(run, e.parentAgentId) && !ownsConversation(run)))) {
703 return {deny: 'DeLM cannot launch work while its conversation is ending or recovering.'};
704 }
705 if (!run || run.finished) return next(e);
706 if (owned(run, e.parentAgentId)) {
707 const result = await next(e);
708 if (result.agentId) {
709 run.descendants[result.agentId] = {parent: e.parentAgentId, status: 'running'};
710 await persist($, run);
711 }
712 return result;
713 }
714 if (e.parentAgentId || run.mainTurn !== run.launchTurn) return next(e);
715 if (run.launches >= 2) return {deny: 'DeLM already started its two native peers. Resume an existing peer if needed.'};
716 if (!e.fork || e.subagentType !== 'fork' || e.name || e.isTeammate) {
717 return {deny: 'DeLM requires an unnamed native fork to preserve the current conversation and setup.'};
718 }
719 const slot = ++run.launches;
720 let release;
721 const gate = new Promise(resolve => { release = resolve; });
722 const binding = {promise: gate, agentId: null, done: false};
723 run.bindings.push(binding);
724 try {
725 const result = await next({...e, cwd: run.ready.workers[slot - 1].cwd, background: true});
726 if (!result.agentId) throw new Error(result.deny || 'Claude did not return a native peer identity.');
727 binding.agentId = result.agentId;
728 run.agents[result.agentId] = {slot, status: 'running', turn: null,
729 deliveredRevision: run.ready.revision, resumeRevision: run.ready.revision,
730 acknowledgedRevision: 0, pending: null, lastUpdate: null};
731 rememberPeer(run, result.agentId, slot);
732 await persist($, run);
733 await request($, run, 'bind', {slot, agent_id: result.agentId});
734 await request($, run, 'configure_scopes', {
735 agent_id: result.agentId, scopes: [{path: run.ready.workers[slot - 1].cwd, access: 'write'}],
736 });
737 return result;
738 } catch (error) {
739 await reportFailure($, run, error);
740 try { await request($, run, 'cancel', {reason: 'Native peer launch failed'}); } catch { /* Durable recovery retains the workspace. */ }
741 return {deny: 'DeLM could not bind the native peer: ' + String(error.message || error)};
742 } finally { binding.done = true; release(); }
743 });
744
745 on('turn.step', async function* ($, e, next) {
746 // Capture before any API await: Claude already captured this model request.
747 const captured = [...runs.values()].find(value => owned(value, e.agentId));
748 const capturedRevision = captured?.agents[e.agentId]?.deliveredRevision ?? 1;
749 const run = await eventRun($, e.agentId);
750 if (run && !e.agentId) {
751 const control = run.controls?.find(value => value.turn === e.turnId);
752 if (obsoleteControl(run, control)) return yield* stoppedStep(e, '');
753 if (control?.kind === 'resume' && hasPendingCompletion(run)) {
754 (run.heldResumes ||= {})[control.agentId] = {message: control.message, control: control.text};
755 return yield* stoppedStep(e, '');
756 }
757 }
758 if (run && !run.finished && !await current($, run)) {
759 return yield* stoppedStep(e, 'DeLM stopped this peer because its conversation ended.');
760 }
761 if (run && owned(run, e.agentId) && !ownsConversation(run)) {
762 return yield* stoppedStep(e, 'DeLM stopped this peer while its run is settling or recovering.');
763 }
764 if (run && owned(run, e.agentId) && run.failure) {
765 return yield* stoppedStep(e, 'DeLM paused this peer: ' + run.failure);
766 }
767 if (ownsConversation(run) && !e.agentId) {
768 await run.updating;
769 }
770 // Claude captures this request's messages before invoking turn.step hooks.
771 // An update stored while this hook waits belongs to a later native request.
772 if (run && !run.finished && e.agentId) {
773 await waitForOwnBinding(run, e.agentId);
774 if (owned(run, e.agentId) && !ownsConversation(run)) {
775 return yield* stoppedStep(e, 'DeLM stopped this peer while its run is settling or recovering.');
776 }
777 const worker = run.agents[e.agentId];
778 if (worker) {
779 try {
780 await run.updating;
781 while (worker.deliveredRevision < run.revision) await deliveryGate(run, e.agentId).promise;
782 if (worker.deliveredRevision !== capturedRevision
783 || (worker.resumeRevision || 1) < worker.deliveredRevision) {
784 return yield* stoppedStep(e, 'DeLM is resuming this peer with the updated task context.');
785 }
786 if (!await current($, run)) throw new Error('The conversation ended before this peer could continue.');
787 await request($, run, 'step', {agent_id: e.agentId, turn_id: e.turnId, revision: worker.deliveredRevision});
788 if (!await current($, run)) throw new Error('The conversation changed during native step admission.');
789 worker.acknowledgedRevision = worker.deliveredRevision;
790 worker.turn = e.turnId;
791 worker.status = 'running';
792 updateBoard($, run);
793 } catch (error) {
794 await reportFailure($, run, error);
795 return yield* stoppedStep(e, 'DeLM paused this peer because its native coordination state is unavailable.');
796 }
797 }
798 }
799 return yield* next(e);
800 }).catch(async function* ($, e, next) {
801 const active = await eventRun($, e.agentId);
802 if (active && owned(active, e.agentId)) {
803 return yield* stoppedStep(e, 'DeLM paused this peer because native task delivery did not complete.');
804 }
805 return yield* next(e);
806 });
807
808 on('session.send', async ($, e, next) => {
809 const run = await eventRun($, e.to);
810 if (run && !run.finished && !await current($, run)) return {isDelivered: false, reason: 'This DeLM peer belongs to another or ended conversation.'};
811 const pending = run?.agents[e.to]?.pending;
812 if (run && owned(run, e.to) && !ownsConversation(run)) return {isDelivered: false, reason: 'This DeLM run is not accepting worker updates. Finish or recover it through its controls.'};
813 if (!run || run.finished || !pending || e.agentId || e.origin.kind !== 'model') return next(e);
814 if (e.text !== pending.message) {
815 return {isDelivered: false, reason: 'Send the exact DeLM task update supplied for this peer, without paraphrasing.'};
816 }
817 const result = await next(e);
818 if (result.isDelivered && await current($, run)) {
819 delivered(run, e.to, pending.revision, true);
820 await persist($, run);
821 }
822 return result;
823 });
824
825 on('session.receive', async ($, e, next) => {
826 const run = await eventRun($, e.agentId);
827 if (run && owned(run, e.agentId) && !ownsConversation(run)) return {consumed: 'This DeLM run is not accepting worker updates.'};
828 if (run && !run.finished && !await current($, run)) return {consumed: 'This DeLM conversation has ended.'};
829 const pending = run?.agents[e.agentId]?.pending;
830 const result = await next(e);
831 if (run && !run.finished && pending && await current($, run) && e.origin.kind === 'coordinator'
832 && e.text === pending.message && result.text === pending.message && !result.consumed) {
833 delivered(run, e.agentId, pending.revision, true);
834 await persist($, run);
835 }
836 return result;
837 });
838
839 on('tool.call', async ($, e, next) => {
840 const run = await eventRun($, e.agentId);
841 if (run && owned(run, e.agentId) && (!await current($, run) || !ownsConversation(run))) {
842 return {deny: 'This DeLM conversation has ended. Return to it and use /delm-stop to recover.'};
843 }
844 const worker = run?.agents[e.agentId];
845 if (e.tool.startsWith(TOOL_PREFIX)) {
846 if (!run || run.finished || !worker?.turn) return {deny: 'Use /delm:run to bind these tools to native DeLM peers.'};
847 let ticket;
848 try {
849 const arguments_ = toolArguments(e);
850 const reservation = await request($, run, 'reserve', {
851 agent_id: e.agentId, turn_id: worker.turn, call_id: e.tool_use_id,
852 tool: e.tool.slice(TOOL_PREFIX.length), arguments: arguments_,
853 });
854 ticket = reservation.ticket;
855 if (typeof ticket !== 'string') throw new Error('Missing native invocation ticket.');
856 // next executes Claude's own MCP permissions, approval UI, and server transport.
857 return await next({...e, _delm: {socket: run.ready.socket, ticket}});
858 } catch (error) {
859 return {deny: 'DeLM refused this coordination call: ' + String(error.message || error)};
860 } finally {
861 if (ticket && !run.finished) {
862 try { await request($, run, 'revoke', {ticket}); } catch { /* Tickets are single-use and never confer control authority. */ }
863 }
864 }
865 }
866 if (!run || run.finished || !owned(run, e.agentId) || e.tool !== 'Bash') return next(e);
867 let nativeResult;
868 try {
869 if (worker) {
870 await request($, run, 'command_start', {
871 agent_id: e.agentId, turn_id: worker.turn, call_id: e.tool_use_id,
872 command: e.command, cwd: await $.session.cwd(),
873 });
874 }
875 const result = await next(e);
876 nativeResult = result;
877 const outcome = commandOutcome(result);
878 if (outcome.background_task_id) {
879 run.background[outcome.background_task_id] = {agent: e.agentId, toolUseId: e.tool_use_id, stopped: false};
880 await persist($, run);
881 }
882 if (worker && outcome.result_ref !== null) {
883 await request($, run, 'command_end', {
884 agent_id: e.agentId, call_id: e.tool_use_id, ...outcome,
885 });
886 }
887 return result;
888 } catch (error) {
889 // Stopping a peer aborts its in-flight bookkeeping. Settlement proves
890 // shutdown independently, so that abort is not a run failure.
891 if (!run.stopping && !run.finished) await reportFailure($, run, error);
892 if (nativeResult) return nativeResult;
893 return {deny: 'DeLM could not record this native command: ' + String(error.message || error)};
894 }
895 });
896
897 on('classic.SubagentStop', async ($, e, next) => {
898 const run = await eventRun($, e.agent_id);
899 if (run && owned(run, e.agent_id) && Array.isArray(e.background_tasks)) {
900 let changed = false;
901 const running = new Set(e.background_tasks.map(task => task.id));
902 for (const [id, task] of Object.entries(run.background)) {
903 if (task.agent === e.agent_id && !running.has(id) && !task.stopped) {
904 task.stopped = true; changed = true;
905 }
906 }
907 await persist($, run);
908 if (changed) {
909 run.proofVersion = (run.proofVersion || 0) + 1;
910 scheduleSettle($, run);
911 }
912 }
913 return next(e);
914 });
915
916 on('ui.render', {component: 'UserMessage'}, async ($, e, next) => {
917 // Native read-only notification fields, independent of the DeLM board.
918 // Text, a missing task, and another plugin's message prove nothing.
919 const notification = e.props?.task;
920 if (e.props?.origin?.kind === 'task-notification' && notification?.id
921 && ['completed', 'failed', 'killed'].includes(notification.status)) {
922 const run = await currentRun($, false);
923 const task = run?.background?.[notification.id];
924 if (task && !task.stopped && (!notification.toolUseId || notification.toolUseId === task.toolUseId)) {
925 task.stopped = true;
926 task.proof = {source: 'native-task-notification', status: notification.status};
927 run.proofVersion = (run.proofVersion || 0) + 1;
928 $.clock.after(0, async () => {
929 try { await persist($, run); scheduleSettle($, run); }
930 catch (error) { await reportFailure($, run, error); }
931 });
932 }
933 }
934 return next(e);
935 });
936
937 on('turn.complete', async ($, e, next) => {
938 const run = await eventRun($, e.agentId);
939 if (run && !await current($, run)) return next(e);
940 const worker = run?.agents[e.agentId];
941 if (run && !run.finished && !e.agentId && e.turnId === run.launchTurn
942 && Object.keys(run.agents).length !== 2) {
943 const reason = 'Claude did not complete the required two-peer native launch.';
944 await reportFailure($, run, new Error(reason));
945 try { await request($, run, 'cancel', {reason}); }
946 catch (error) { await reportFailure($, run, error); }
947 }
948 if (run && !run.finished && owned(run, e.agentId)) {
949 owned(run, e.agentId).status = e.isAborted ? 'killed' : e.reason === 'answer' ? 'completed' : 'failed';
950 await persist($, run);
951 if (worker) {
952 try {
953 if (worker.turn === e.turnId) {
954 await request($, run, 'turn_end', {
955 agent_id: e.agentId, turn_id: e.turnId,
956 reason: e.reason === 'answer' && !e.isAborted ? 'completed' : e.isAborted ? 'interrupted' : 'failed',
957 answer: e.answer,
958 });
959 }
960 worker.turn = null;
961 updateBoard($, run);
962 if (e.reason === 'answer' && !e.isAborted && !run.stopping && worker.lastUpdate
963 && worker.lastUpdate.revision > worker.acknowledgedRevision) {
964 await resume($, run, {agent_id: e.agentId, ...worker.lastUpdate});
965 }
966 } catch (error) { if (!run.stopping) await reportFailure($, run, error); }
967 }
968 }
969 return next(e);
970 });
971
972 on('prompt.submit', async ($, e, next) => {
973 const run = await currentRun($, false);
974 if (!run) restoreInBackground($);
975 if (e.origin.kind === 'task-notification') {
976 const handled = peerNotification(run, await $.session.id(), e);
977 if (handled) return {drop: handled};
978 }
979 if (run && e.origin.kind === 'plugin' && e.origin.name === $.plugin.name) {
980 const control = run.controls?.find(value => !value.admitted && value.text === e.text);
981 if (control) {
982 if (obsoleteControl(run, control)) return {drop: 'DeLM no longer needs this worker control.'};
983 if (control.kind === 'resume' && hasPendingCompletion(run)) {
984 (run.heldResumes ||= {})[control.agentId] = {message: control.message, control: control.text};
985 // Retire this admission so a later event-driven retry has its own
986 // record; no parent model call is needed while completion is pending.
987 control.admitted = true;
988 control.turn = 'held';
989 return {drop: 'DeLM is waiting for a native completion already in progress.'};
990 }
991 control.admitted = true;
992 await persist($, run);
993 return next(e);
994 }
995 }
996 // The run owns execution resources until cleanup, but owns conversation
997 // input only while actively working. Failed/restored runs must not trap
998 // ordinary Claude prompts behind a dead bridge or a stop request.
999 if (!ownsConversation(run) || e.origin.kind === 'plugin' || e.text.startsWith('/')) return next(e);
1000 if (!['composer', 'bridge', 'sdk'].includes(e.origin.kind)) return next(e);
1001 try { followupText(e); }
1002 catch (error) { return {drop: String(error.message || error)}; }
1003 const previous = run.updating;
1004 let release;
1005 run.updating = new Promise(resolve => { release = resolve; });
1006 const control = 'DeLM is forwarding this update to its native peers. Keep this parent a lightweight control channel; do not duplicate their implementation or checks.';
1007 let accepted;
1008 let admitted = false;
1009 try {
1010 await previous;
1011 if (!ownsConversation(run)) return next(e);
1012 if (!await current($, run)) return {drop: 'The conversation changed before DeLM could forward this update.'};
1013 // Native and installed prompt middleware may refuse or rewrite input.
1014 // Forward its accepted text and additional context, never a discarded prompt.
1015 accepted = await next({...e, context: [...(e.context || []), control]});
1016 if (accepted.drop) return accepted;
1017 if (!await current($, run)) throw new Error('The conversation changed before this update reached DeLM.');
1018 const text = followupText({...e, ...accepted, context: (accepted.context || []).filter(value => value !== control)});
1019 await request($, run, 'update', {text});
1020 admitted = true;
1021 // An accepted revision won before begin_settle closed admission. Fence
1022 // out the old timer and allow this revision's delivery actions through.
1023 if (run.finalization && run.finalization.revision < run.revision) {
1024 run.retryTimer?.cancel(); run.retryTimer = null;
1025 run.finalization = null;
1026 run.stopping = false;
1027 }
1028 run.phase = 'working';
1029 run.failure = null;
1030 run.completionPending = {};
1031 await persist($, run);
1032 releaseHeldResumes($, run);
1033 return accepted;
1034 } catch (error) {
1035 await reportFailure($, run, admitted
1036 ? new Error('DeLM accepted this update, but could not save its control state: ' + String(error.message || error)) : error);
1037 if (admitted) return accepted;
1038 return {drop: 'DeLM could not confirm delivery of this update. ' + String(error.message || error)
1039 + ' It will not be implemented in the parent or replayed automatically. You can continue chatting normally here; use the DeLM board to finish or recover the run.'};
1040 } finally {
1041 release();
1042 }
1043 });
1044
1045 on('session.end', async ($, e, next) => {
1046 const session = e.sessionId || await $.session.id();
1047 restoreAttempts.delete(session);
1048 generations.set(session, (generations.get(session) || 0) + 1);
1049 const run = runs.get(session);
1050 restores.delete(session);
1051 if (run && !run.finished) {
1052 run.ending = true;
1053 run.mainTurn = null;
1054 for (const gate of Object.values(run.deliveries)) gate.reject(new Error('Conversation ended before task delivery.'));
1055 run.deliveries = {};
1056 await persist($, run);
1057 // The host's total session.end budget is 1.5 seconds. Cancel admission
1058 // promptly; confirm native shutdown through recovery when returning.
1059 try { await request($, run, run.finalization?.intent === 'deliver' ? 'interrupt' : 'cancel', {reason: 'Conversation ended: ' + e.reason}, 750); }
1060 catch { /* Durable recovery keeps unfinished work and verifies shutdown. */ }
1061 }
1062 return next(e);
1063 });
1064}
1065hooks/protocol.js 116 lines1export const TOOL_PREFIX = 'mcp__plugin_delm_delm__';
2
3export function parseReply(text) {
4 const reply = JSON.parse(text);
5 if (!reply || reply.ok !== true) {
6 throw new Error(reply?.error || 'DeLM did not return a successful response.');
7 }
8 return reply.result;
9}
10
11export function validateReady(value) {
12 if (!value || value.type !== 'ready' || typeof value.run_id !== 'string'
13 || typeof value.socket !== 'string' || !value.socket.startsWith('/')
14 || typeof value.token !== 'string' || value.token.length < 16
15 || typeof value.executable !== 'string' || !value.executable.startsWith('/')
16 || !Array.isArray(value.workers) || value.workers.length !== 2
17 || value.workers.some((worker, index) => worker?.slot !== index + 1
18 || typeof worker.cwd !== 'string' || !worker.cwd.startsWith('/'))
19 || value.workers[0].cwd === value.workers[1].cwd
20 || !Number.isSafeInteger(value.revision) || value.revision < 1) {
21 throw new Error('DeLM returned an invalid native launch contract.');
22 }
23 return value;
24}
25
26export function toolArguments(event) {
27 if (Object.hasOwn(event, '_delm')) {
28 throw new Error('DeLM coordination credentials must come from the native host.');
29 }
30 return Object.fromEntries(Object.entries(event)
31 .filter(([key]) => !['tool', 'tool_use_id', 'agentId'].includes(key)));
32}
33
34export function commandOutcome(result) {
35 const native = result?.result;
36 const record = native && typeof native === 'object' && !Array.isArray(native) ? native : {};
37 const reference = result?.ref;
38 return {
39 result_ref: typeof reference === 'number' || typeof reference === 'string'
40 ? String(reference) : null,
41 is_error: Boolean(result?.isError || result?.deny),
42 interrupted: record.interrupted === true,
43 background_task_id: typeof record.backgroundTaskId === 'string'
44 ? record.backgroundTaskId : null,
45 timed_out: typeof record.timedOutAfterMs === 'number',
46 };
47}
48
49export function completeState(status) {
50 return ['completed', 'failed', 'killed', 'finished'].includes(status);
51}
52
53export function nativeLaunchContext(task, workerPrompt) {
54 return [
55 'DeLM prepared this explicit request. Start exactly two native fork Agent calls in the same response.',
56 'Use subagent_type "fork" and omit model/name/isolation. Both forks inherit this entire conversation, including the worker contract and user request below.',
57 'Use this short prompt for each Agent call: "Follow the inherited DeLM worker contract and user request. You are one of two peers. Begin useful work through the shared board now."',
58 'Do not copy the contract or user request into the Agent calls; they are already inherited.',
59 'Do not inspect the project, plan the implementation, execute shell commands, or do the task in this parent turn.',
60 'The native plugin binds each fork to its own prepared workspace. Do not choose a workspace yourself.',
61 '',
62 'Worker instructions:',
63 workerPrompt,
64 '',
65 'Claude Code host instructions: preserve your inherited setup and use its skills and tools normally.',
66 'Use the native DeLM MCP tools for shared coordination. Their native identity is assigned by the host.',
67 'Run commands through native Bash. recent_commands contains the observed native IDs for check receipts.',
68 'After delm_complete, end your turn promptly. Native permissions remain authoritative.',
69 '',
70 'User request:',
71 task,
72 ].join('\n');
73}
74
75export function decodeLines(buffer, chunk) {
76 const combined = buffer + chunk;
77 if (combined.length > 2 * 1024 * 1024) {
78 throw new Error('DeLM exceeded its native event size limit.');
79 }
80 const lines = combined.split('\n');
81 const rest = lines.pop();
82 return {rest, events: lines.filter(line => line.trim()).map(line => JSON.parse(line))};
83}
84
85export function followupText(prompt) {
86 if (typeof prompt.text !== 'string' || !Array.isArray(prompt.context || [])
87 || (prompt.context || []).some(value => typeof value !== 'string')) {
88 throw new Error('DeLM could not read the complete text of this update. The peers still have the previous task.');
89 }
90 if (prompt.attachments?.length) {
91 const kinds = [...new Set(prompt.attachments.map(item => item.type))].join(', ');
92 throw new Error('Claude cannot forward ' + kinds + ' attachments into existing DeLM peers. '
93 + 'This update was not sent; the peers still have the previous task. '
94 + 'Use /delm-stop, then start /delm:run with the attachment so both native forks inherit it.');
95 }
96 // Inline/fenced code and quoted literals are not reference submissions.
97 const prose = prompt.text.replace(/```[\s\S]*?```|`[^`\n]*`|(?<!@)"[^"\n]*"|(?<![\p{L}\p{N}_@])'[^'\n]*'(?![\p{L}\p{N}_])/gu, '');
98 if (/(^|\s)@(?:"[^"\n]+"|[^\s@]+)/u.test(prose)) {
99 throw new Error('Claude expands @references after this forwarding boundary. '
100 + 'This update was not sent; the peers still have the previous task. '
101 + 'Paste the relevant text, or use /delm-stop and include the reference in a new /delm:run.');
102 }
103 const context = (prompt.context || []).filter(Boolean);
104 const text = prompt.text + (context.length ? '\n\nAdditional context:\n' + context.join('\n\n') : '');
105 let bytes = 0;
106 for (const character of text) {
107 const point = character.codePointAt(0);
108 bytes += point < 0x80 ? 1 : point < 0x800 ? 2 : point < 0x10000 ? 3 : 4;
109 }
110 if (!text.trim() || bytes > 128 * 1024) {
111 throw new Error('DeLM needs a nonempty update of at most 128 KiB including additional context. '
112 + 'This update was not sent; shorten it and retry.');
113 }
114 return text;
115}
116hooks/board-view.js 593 lines1import {renderBoard, renderCompact, boardSummary, cleanText} from './board-render.js';
2
3const PANE = 'delm';
4const STORE = 'native-board:';
5const MAX_LINE = 256 * 1024;
6const COLLECTIONS = new Set(['tasks', 'shared', 'checks']);
7const observedRuns = new Map();
8const retryFinishActions = new Map();
9const preferenceWrites = new Map();
10
11export function observeBoard(run, retryFinish) {
12 // An explicit action closure stays in the host module's native API scope.
13 // Passing the native $ context through a dynamic callback is not supported.
14 if (retryFinish) retryFinishActions.set(run.session, retryFinish);
15 // Copy only presentation facts, never the transport credentials or worker messages.
16 observedRuns.set(run.session, {
17 session: run.session, ready: {run_id: run.ready?.run_id}, revision: run.revision,
18 agents: Object.fromEntries(nativeAgents(run).map(agent => [agent.id, agent])),
19 finished: run.finished, ending: run.ending, stopping: run.stopping, failure: run.failure,
20 phase: run.phase, canRetryFinish: Boolean(run.canRetryFinish), conversationAvailable: Boolean(run.conversationAvailable),
21 final: run.final ? {status: run.final.status, delivery: run.final.delivery} : null,
22 });
23}
24
25function nativeAgents(run) {
26 return Object.entries(run?.agents || {}).map(([id, agent]) => ({
27 id, slot: agent.slot, status: agent.status, turn: agent.turn,
28 deliveredRevision: agent.deliveredRevision, acknowledgedRevision: agent.acknowledgedRevision,
29 }));
30}
31
32function initialSnapshot(run, session) {
33 const final = run?.final || {};
34 const delivery = final.delivery || {};
35 const artifacts = Array.isArray(delivery.artifacts) ? delivery.artifacts : [];
36 const undelivered = Array.isArray(delivery.undelivered_outputs) ? delivery.undelivered_outputs : [];
37 return {
38 schema_version: 1, type: 'view', session_id: session, run_id: run?.ready?.run_id || null,
39 revision: run?.revision || 1, status: run?.failure ? 'recovery_required'
40 : final.status || run?.phase || (run?.ending ? 'interrupted' : run?.stopping ? 'stopping' : 'prepared'),
41 finished: Boolean(run?.finished), agents: nativeAgents(run).map(agent => ({
42 slot: agent.slot, native_agent_id: agent.id,
43 native_state: agent.turn && agent.status === 'running' ? 'working'
44 : ['completed', 'killed', 'failed'].includes(agent.status) ? 'stopped' : 'starting',
45 task_ids: [],
46 })),
47 tasks: {items: [], total: 0}, shared: {items: [], total: 0}, checks: {items: [], total: 0},
48 outcome: {
49 delivered: delivery.delivered ?? null,
50 verification_required: delivery.verification_required ?? null,
51 cleanup_complete: delivery.cleanup_complete ?? null,
52 reason: run?.failure ? cleanText(run.failure) : null,
53 artifacts: artifacts.slice(0, 16).map(path => cleanText(path, 512)),
54 artifacts_total: artifacts.length,
55 artifacts_declared: delivery.artifacts_declared === true,
56 undelivered_outputs: undelivered.slice(0, 16).map(path => cleanText(path, 512)),
57 undelivered_outputs_total: undelivered.length,
58 }, freshness: {unavailable: ['board']},
59 };
60}
61
62export function validateSnapshot(value, session, runId) {
63 if (value?.type !== 'view' || value.schema_version !== 1 || value.session_id !== session
64 || value.run_id !== runId || !Number.isSafeInteger(value.revision) || value.revision < 1
65 || typeof value.status !== 'string' || typeof value.finished !== 'boolean') {
66 throw new Error('The DeLM board received an incompatible snapshot.');
67 }
68 for (const name of COLLECTIONS) {
69 const group = value[name];
70 if (!group || !Array.isArray(group.items) || group.items.length > 32
71 || !Number.isSafeInteger(group.total) || group.total < group.items.length) {
72 throw new Error('The DeLM board received an invalid collection.');
73 }
74 }
75 if (!Array.isArray(value.agents) || value.agents.length > 32 || !value.source
76 || !['controller_sequence', 'board_sequence'].every(key => Number.isSafeInteger(value.source[key]) && value.source[key] >= 0)) {
77 throw new Error('The DeLM board received invalid source state.');
78 }
79 return value;
80}
81
82export function acceptsSnapshot(previous, incoming) {
83 if (!previous?.source) return true;
84 return incoming.revision >= previous.revision
85 && incoming.source.controller_sequence >= previous.source.controller_sequence
86 && incoming.source.board_sequence >= previous.source.board_sequence;
87}
88
89// Observation never mutates lifecycle. The explicit retry control delegates to
90// the host, which revalidates the current session and delivery intent.
91let active = null;
92let generation = 0;
93let lookupOverride;
94let retryFinishAction;
95function canRetry(session) { return retryFinishActions.has(session) || Boolean(retryFinishAction); }
96function retryFinishing(session) {
97 const action = retryFinishActions.get(session);
98 return action ? action() : retryFinishAction?.(session);
99}
100
101async function readRun($, session) {
102 const value = lookupOverride ? await lookupOverride(null, session)
103 : observedRuns.get(session) || await $.store.get('native-run:' + session);
104 return value?.session === session ? value : null;
105}
106
107const valid = state => active === state && state.generation === generation && !state.disposed;
108function cancel(timer) { try { timer?.cancel?.(); } catch {} }
109function ignore(promise) { Promise.resolve(promise).catch(() => {}); }
110function invalidate($, state) {
111 if (!valid(state) || state.paint) return;
112 state.paint = $.clock.after(250, () => {
113 state.paint = null;
114 if (valid(state)) { try { $.ui.invalidate('ui.render'); } catch {} }
115 });
116}
117function remember($, state) {
118 const value = {version: 1, runId: state.runId, hidden: state.hidden,
119 finalSnapshot: state.snapshot?.finished ? state.snapshot : null};
120 // Run replacement must not let an older delayed write erase the new preference.
121 const writing = (preferenceWrites.get(state.session) || Promise.resolve())
122 .then(() => $.store.set(STORE + state.session, value)).catch(() => {});
123 state.writes = writing;
124 preferenceWrites.set(state.session, writing);
125 writing.then(() => {
126 if (preferenceWrites.get(state.session) === writing) preferenceWrites.delete(state.session);
127 });
128}
129function endStream(state) {
130 state.streamGeneration++;
131 const stream = state.stream;
132 state.stream = null;
133 if (stream?.return) ignore(stream.return());
134 cancel(state.retry); state.retry = null;
135}
136function dispose(state) {
137 if (!state) return;
138 state.disposed = true;
139 endStream(state); cancel(state.paint); cancel(state.paneTimer); cancel(state.nativeTimer);
140 cancel(state.navigationTimer);
141 state.detailGeneration++;
142}
143function replace(session, run) {
144 dispose(active);
145 const state = {
146 session, runId: run?.ready?.run_id || null, generation: ++generation,
147 snapshot: run ? initialSnapshot(run, session) : null,
148 nativeAgents: nativeAgents(run), nativeRevision: 0,
149 canRetryFinish: Boolean(canRetry(session) && run?.canRetryFinish), conversationAvailable: Boolean(run?.conversationAvailable),
150 hostFailure: run?.failure ? cleanText(run.failure, 600) : null,
151 screen: {kind: 'overview'}, pages: {}, pagePrevious: {}, history: [], hidden: false, paneShown: false,
152 disconnected: false, attention: null, phase: run ? null : 'preparing',
153 streamGeneration: 0, detailGeneration: 0, retries: 0, writes: Promise.resolve(),
154 };
155 active = state;
156 return state;
157}
158function problem($, state, error) {
159 if (!valid(state)) return;
160 state.disconnected = true;
161 state.attention = 'Board updates unavailable. Use /delm-status to retry.';
162 state.viewError = cleanText(error?.message || error, 600);
163 invalidate($, state);
164}
165function syncPanes($, state) {
166 if (!valid(state) || state.paneQuery) return;
167 state.paneQuery = true;
168 Promise.resolve($.ui.panes()).then(panes => {
169 if (!valid(state)) return;
170 const pane = panes.find(item => item.id === PANE);
171 const shown = Boolean(pane?.isPlaced && pane.isShown);
172 if (state.paneShown !== shown) { state.paneShown = shown; invalidate($, state); }
173 }).catch(() => {}).finally(() => { state.paneQuery = false; });
174}
175function startPaneSync($, state) {
176 if (!valid(state) || state.paneTimer || !state.uiReady || state.snapshot?.finished) return;
177 syncPanes($, state);
178 state.paneTimer = $.clock.every(1000, () => syncPanes($, state));
179}
180function open($, state) {
181 if (!valid(state)) return Promise.resolve(false);
182 state.hidden = false;
183 remember($, state);
184 // Called directly from the user's command or button, preserving native placement.
185 let opened;
186 try { opened = $.ui.open({id: PANE, title: 'DeLM', rows: 24, columns: 54}); }
187 catch (error) { problem($, state, error); return Promise.resolve(false); }
188 return Promise.resolve(opened).then(result => {
189 if (!valid(state)) return false;
190 state.uiReady = true;
191 state.paneShown = Boolean(result.isPlaced);
192 startPaneSync($, state);
193 // Preparation may finish while the native pane is still opening.
194 if (state.snapshot?.finished && !state.snapshot.source) ignore(readFinal($, state));
195 else watch($, state);
196 invalidate($, state);
197 return result.isPlaced;
198 }).catch(error => { problem($, state, error); return false; });
199}
200function argv($, state, extra = []) {
201 return [$.plugin.root + '/bin/delm', 'claude', 'view', '--run-id', state.runId,
202 '--session-id', state.session, ...extra];
203}
204// The observer explains refusals on stderr. Keep its first line and cause so
205// the board can say why updates stopped instead of only that they did.
206export function observerReason(stderr) {
207 const lines = String(stderr || '').split('\n').map(line => line.trim()).filter(Boolean);
208 if (!lines.length) return null;
209 const message = lines[0].replace(/^Error:\s*/, '');
210 const caused = lines.indexOf('Caused by:');
211 const cause = caused >= 0 && lines[caused + 1] ? lines[caused + 1].replace(/^\d+:\s*/, '') : '';
212 return cleanText(cause && cause !== message ? `${message}: ${cause}` : message, 600) || null;
213}
214async function readOnce($, state, extra = []) {
215 const result = await $.process.run(argv($, state, extra), {stdin: '', timeoutMs: 3000, cwd: $.plugin.root});
216 if (result.exitCode !== 0 || result.isStdoutTruncated || result.stdout.length > MAX_LINE) {
217 throw new Error(observerReason(result.stderr) || 'DeLM could not read this saved board.');
218 }
219 return validateSnapshot(JSON.parse(result.stdout), state.session, state.runId);
220}
221function readFinal($, state) {
222 if (state.finalRead) return state.finalRead;
223 state.finalRead = readOnce($, state).then(value => accept($, state, value)).catch(error => {
224 if (!state.snapshot?.source) problem($, state, error);
225 }).finally(() => { state.finalRead = null; });
226 return state.finalRead;
227}
228function accept($, state, value) {
229 if (!valid(state) || !acceptsSnapshot(state.snapshot, value)) return false;
230 state.snapshot = value;
231 state.phase = !value.finished && state.hostFailure ? 'recovery_required' : null;
232 state.disconnected = false;
233 state.viewError = null;
234 state.attention = (!value.finished && state.hostFailure) || value.outcome?.reason || null;
235 state.retries = 0;
236 if (value.finished) {
237 remember($, state);
238 cancel(state.retry); state.retry = null;
239 cancel(state.paneTimer); state.paneTimer = null;
240 cancel(state.nativeTimer); state.nativeTimer = null;
241 }
242 invalidate($, state);
243 return true;
244}
245function watch($, state) {
246 if (!valid(state) || !state.runId || state.stream || !state.uiReady || state.snapshot?.finished) return;
247 const stamp = ++state.streamGeneration;
248 const interval = state.hidden ? 1000 : 250;
249 void (async () => {
250 let buffer = '', errors = '';
251 try {
252 // A long-lived observer must never hold a peer workspace as its directory.
253 const stream = $.process.spawn({argv: argv($, state, ['--watch', '--interval-ms', String(interval)]), cwd: $.plugin.root});
254 state.stream = stream;
255 watchRead: for await (const chunk of stream) {
256 if (!valid(state) || state.streamGeneration !== stamp) break;
257 if (chunk.stream === 'stderr') { errors = (errors + chunk.text).slice(-4096); continue; }
258 if (chunk.stream !== 'stdout') continue;
259 buffer += chunk.text;
260 if (buffer.length > MAX_LINE * 2) throw new Error('DeLM board output exceeded its limit.');
261 let index;
262 while ((index = buffer.indexOf('\n')) !== -1) {
263 const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
264 if (line.length > MAX_LINE) throw new Error('DeLM board record exceeded its limit.');
265 if (!line.trim()) continue;
266 const value = validateSnapshot(JSON.parse(line), state.session, state.runId);
267 if (accept($, state, value) && value.finished) break watchRead;
268 }
269 }
270 if (valid(state) && state.streamGeneration === stamp && !state.snapshot?.finished) {
271 throw new Error(observerReason(errors) || 'The DeLM board observer disconnected.');
272 }
273 } catch (error) {
274 if (valid(state) && state.streamGeneration === stamp) {
275 problem($, state, error);
276 if (state.retries < 3) {
277 const wait = [1000, 2500, 5000][state.retries++];
278 state.retry = $.clock.after(wait, () => { state.retry = null; watch($, state); });
279 }
280 }
281 } finally {
282 if (state.streamGeneration === stamp) state.stream = null;
283 }
284 })();
285}
286function restart($, state) {
287 endStream(state);
288 watch($, state);
289}
290function observe($, run) {
291 const state = active;
292 if (!state || !valid(state) || state.session !== run.session) return;
293 if (state.runId && state.runId !== run.ready?.run_id) return;
294 const previousRunId = state.runId;
295 state.runId = run.ready?.run_id || state.runId;
296 state.nativeAgents = nativeAgents(run); state.nativeRevision++;
297 if (!state.snapshot) state.snapshot = initialSnapshot(run, state.session);
298 state.canRetryFinish = Boolean(canRetry(state.session) && run.canRetryFinish && !run.finished);
299 state.conversationAvailable = Boolean(run.conversationAvailable);
300 state.hostFailure = run.failure ? cleanText(run.failure, 600) : null;
301 // Host observations cannot leave a stale stopping phase over a new durable
302 // revision. A current host error is still an independent attention fact.
303 state.phase = !state.snapshot?.finished && state.hostFailure ? 'recovery_required' : state.snapshot?.source ? null
304 : run.phase || (run.stopping && !run.finished ? 'stopping' : null);
305 state.attention = (!state.snapshot?.finished && state.hostFailure) || state.snapshot?.outcome?.reason || null;
306 if (run.finished && !state.snapshot.finished) {
307 // Keep the watcher until it reads the durable final result and coordination state.
308 state.phase = run.final?.status || null;
309 }
310 if (state.runId !== previousRunId) remember($, state);
311 invalidate($, state);
312 if (!state.stream && state.uiReady) watch($, state);
313}
314function startNativeSync($, state) {
315 if (!valid(state) || state.nativeTimer || state.snapshot?.finished) return;
316 state.nativeTimer = $.clock.every(250, async () => {
317 if (!valid(state) || state.nativeQuery) return;
318 state.nativeQuery = true;
319 try {
320 const session = await $.session.id();
321 if (!valid(state)) return;
322 if (session !== state.session) { end($, state.session); return; }
323 const run = observedRuns.get(state.session);
324 if (!run || run === state.lastNative || !run.ready?.run_id
325 || (!state.runId && run.ready.run_id === state.previousRunId)) return;
326 state.lastNative = run;
327 observe($, run);
328 } catch {
329 // A view with unconfirmed conversation ownership must not remain visible.
330 if (valid(state)) end($, state.session);
331 } finally { state.nativeQuery = false; }
332 });
333}
334async function restore($, session, {show = false} = {}) {
335 if (active && active.session !== session) {
336 dispose(active); active = null; generation++;
337 try { $.ui.invalidate('ui.render'); } catch {}
338 }
339 const stamp = generation;
340 const run = await readRun($, session);
341 if (generation !== stamp || await $.session.id() !== session) return null;
342 if (!run) { dispose(active); active = null; generation++; return null; }
343 let state = active;
344 if (!state || state.session !== session || state.runId !== run.ready?.run_id) {
345 state = replace(session, run);
346 const saved = await $.store.get(STORE + session);
347 if (!valid(state)) return null;
348 if (saved?.version === 1 && saved.runId === state.runId) {
349 state.hidden = Boolean(saved.hidden);
350 if (saved.finalSnapshot && state.runId) {
351 try { state.snapshot = validateSnapshot(saved.finalSnapshot, session, state.runId); } catch {}
352 }
353 }
354 }
355 state.nativeAgents = nativeAgents(run);
356 if (!state.snapshot?.source) state.snapshot = initialSnapshot(run, session);
357 startNativeSync($, state);
358 if (show) await open($, state);
359 else if (!state.hidden) ignore(open($, state));
360 else state.uiReady = true;
361 startPaneSync($, state);
362 if (state.runId && state.uiReady) {
363 if (state.snapshot?.finished) await readFinal($, state);
364 else watch($, state);
365 }
366 return state;
367}
368async function page($, state, collection, offset = 0, itemId = null, fresh = false) {
369 if (!valid(state) || !state.runId || !COLLECTIONS.has(collection)) return;
370 const stamp = ++state.detailGeneration;
371 const current = state.pages[collection] || state.snapshot?.[collection];
372 const extra = ['--collection', collection];
373 if (itemId != null) extra.push('--item-id', String(itemId));
374 else {
375 extra.push('--offset', String(Math.max(0, Number(offset) || 0)), '--limit', '8');
376 if (!fresh && Number.isSafeInteger(current?.through_sequence)) extra.push('--through-sequence', String(current.through_sequence));
377 }
378 state.detailLoading = true; state.detailError = null; invalidate($, state);
379 try {
380 let value = await readOnce($, state, extra);
381 const currentEnough = record => record.revision >= (state.snapshot?.revision || 0)
382 && record.source.board_sequence >= (state.snapshot?.source?.board_sequence || 0);
383 if (valid(state) && state.detailGeneration === stamp && !currentEnough(value)) value = await readOnce($, state, extra);
384 if (!valid(state) || state.detailGeneration !== stamp) return;
385 if (!currentEnough(value)) throw new Error('The board changed while this page was loading.');
386 const records = value[collection];
387 if (itemId != null) return {...records, view_revision: value.revision, view_board_sequence: value.source.board_sequence};
388 const anchorChanged = current?.through_sequence !== records.through_sequence;
389 const previous = anchorChanged ? (state.pagePrevious[collection] = {}) : (state.pagePrevious[collection] ||= {});
390 if (!anchorChanged && current?.next_offset === records.offset && records.offset > (current.offset || 0)) {
391 previous[records.offset] = current.offset || 0;
392 }
393 state.pages[collection] = {...records, previous_offset: previous[records.offset] ?? null,
394 view_revision: value.revision, view_board_sequence: value.source.board_sequence};
395 return state.pages[collection];
396 } catch (error) { if (valid(state) && state.detailGeneration === stamp) state.detailError = 'This page is unavailable. Try again.'; }
397 finally {
398 if (valid(state) && state.detailGeneration === stamp) { state.detailLoading = false; invalidate($, state); }
399 }
400}
401function navigateScroll($, state, to) {
402 cancel(state.navigationTimer);
403 try { $.ui.invalidate('ui.render'); } catch {}
404 state.navigationTimer = $.clock.after(0, () => {
405 if (!valid(state)) return;
406 try { ignore($.ui.scroll({to, in: PANE, block: 'nearest'})); } catch {}
407 });
408}
409async function refresh($, state) {
410 const screen = state.screen;
411 const collection = screen.collection || screen.kind;
412 if (!COLLECTIONS.has(collection)) return;
413 const offset = screen.id == null ? state.pages[collection]?.offset ?? screen.pageOffset ?? 0
414 : screen.pageOffset ?? 0;
415 const records = await page($, state, collection, offset, screen.id ?? null, true);
416 if (!valid(state) || state.screen !== screen || !records || screen.id == null) return;
417 const item = records.items.find(item => String(item.id) === String(screen.id));
418 if (!item) {
419 state.detailError = 'This item is no longer available. The previous details remain below.';
420 invalidate($, state);
421 return;
422 }
423 screen.item = item;
424 screen.view_revision = records.view_revision;
425 screen.view_board_sequence = records.view_board_sequence;
426 invalidate($, state);
427}
428function actions($, state) {
429 const navigate = (screen, returnKey) => {
430 state.history.push({...state.screen, returnKey}); state.screen = screen;
431 state.detailError = null; navigateScroll($, state, 'start');
432 };
433 return {
434 details: () => { if (valid(state)) navigate({kind: 'details'}, 'board-details'); },
435 select: (kind, id) => {
436 if (!valid(state)) return;
437 const collection = kind === 'task' ? 'tasks' : kind === 'contribution' || kind === 'shared' ? 'shared' : kind;
438 const group = (state.screen.kind === collection ? state.pages[collection] : null) || state.snapshot?.[collection];
439 const item = group?.items.find(value => String(value.id) === String(id));
440 const key = id == null ? (state.screen.kind === 'details' ? 'detail-' : 'view-all-') + collection
441 : (collection === 'tasks' ? 'task-' : 'shared-') + id;
442 navigate({kind, id, item, collection, pageOffset: group?.offset || 0,
443 view_revision: group?.view_revision ?? state.snapshot?.revision,
444 view_board_sequence: group?.view_board_sequence ?? state.snapshot?.source?.board_sequence}, key);
445 if (id == null && COLLECTIONS.has(collection)) ignore(page($, state, collection, 0, null, true));
446 else if (id != null && !item && COLLECTIONS.has(collection)) ignore(refresh($, state));
447 },
448 back: () => {
449 if (!valid(state)) return;
450 state.detailGeneration++; state.detailLoading = false;
451 state.screen = state.history.pop() || {kind: 'overview'};
452 navigateScroll($, state, state.screen.returnKey ? {key: state.screen.returnKey} : 'start');
453 },
454 page: (collection, offset) => { ignore(page($, state, collection, offset)); },
455 refresh: () => { if (valid(state)) ignore(refresh($, state)); },
456 retryFinish: () => {
457 if (!valid(state) || !state.canRetryFinish || state.retryingFinish || !canRetry(state.session)) return;
458 state.retryingFinish = true; invalidate($, state);
459 ignore((async () => {
460 try {
461 if (await $.session.id() !== state.session || !valid(state)) return;
462 await retryFinishing(state.session);
463 if (valid(state)) {
464 const run = await readRun($, state.session);
465 if (run && valid(state)) observe($, run);
466 }
467 } catch (error) {
468 if (valid(state)) state.attention = cleanText(error?.message || error, 600);
469 } finally {
470 if (valid(state)) { state.retryingFinish = false; invalidate($, state); }
471 }
472 })());
473 },
474 hide: () => {
475 if (!valid(state)) return;
476 state.hidden = true; state.paneShown = false; remember($, state);
477 ignore($.ui.close({id: PANE})); restart($, state); invalidate($, state);
478 },
479 show: () => { if (valid(state)) { ignore(open($, state)); state.retries = 0; restart($, state); } },
480 };
481}
482function draw($, e, next, compact) {
483 const state = active;
484 if (!state || !valid(state)) return next(e);
485 const viewed = e.props?.view?.agentId;
486 if (viewed && !state.nativeAgents.some(agent => agent.id === viewed)) return next(e);
487 const native = observedRuns.get(state.session);
488 const screen = state.screen;
489 const pageSource = state.pages[screen.kind] || (screen.item ? screen : null);
490 const rendering = {...state, selectedAgentId: viewed,
491 detailStale: pageSource && (pageSource.view_revision < state.snapshot?.revision
492 || pageSource.view_board_sequence < state.snapshot?.source?.board_sequence),
493 detailRevision: pageSource?.view_revision,
494 nativeAgents: native ? nativeAgents(native) : state.nativeAgents};
495 try {
496 const components = $.ui.resolve(e);
497 const result = compact ? renderCompact(components, e, rendering, actions($, state)) : renderBoard(components, e, rendering, actions($, state));
498 return result ?? next(e);
499 } catch {
500 // Fall back using only native primitives; no observer or lifecycle action runs here.
501 try { const {Text} = $.ui.resolve(e); return Text({children: 'DeLM board unavailable. Use /delm-status.'}); }
502 catch { return next(e); }
503 }
504}
505function begin($, session, previousRunId = null) {
506 const state = replace(session, null);
507 state.previousRunId = previousRunId;
508 ignore(open($, state));
509 startNativeSync($, state);
510}
511function failure($, session, error) {
512 if (active?.session === session) {
513 active.phase = 'error'; active.attention = cleanText(error?.message || error, 600);
514 cancel(active.nativeTimer); active.nativeTimer = null;
515 cancel(active.paneTimer); active.paneTimer = null;
516 invalidate($, active);
517 }
518}
519async function status($, session) {
520 const state = await restore($, session, {show: true});
521 if (!state) return {text: 'No DeLM run in this conversation.'};
522 if (state.uiReady && state.paneShown) return {};
523 return {text: boardSummary(state)};
524}
525function end($, session) {
526 if (active?.session !== session) return;
527 dispose(active); active = null; generation++;
528 try { ignore($.ui.close({id: PANE})); } catch {}
529 try { $.ui.invalidate('ui.render'); } catch {}
530}
531async function clearForeignView($) {
532 const state = active, stamp = generation;
533 try {
534 const session = await $.session.id();
535 if (generation !== stamp) return null;
536 if (state && valid(state) && state.session !== session) end($, state.session);
537 return session;
538 } catch {
539 if (state && valid(state)) end($, state.session);
540 return null;
541 }
542}
543export function registerBoard(on, {lookup, retryFinish} = {}) {
544 dispose(active); active = null; generation++;
545 observedRuns.clear(); retryFinishActions.clear(); lookupOverride = lookup; retryFinishAction = retryFinish;
546 on('ui.render', {component: 'Pane', requestId: PANE}, ($, e, next) => draw($, e, next, false));
547 on('ui.render', {component: 'AbovePrompt'}, ($, e, next) => draw($, e, next, true));
548 on('ui.close', {id: PANE}, async ($, e, next) => {
549 const state = active;
550 const result = await next(e);
551 if (state && valid(state) && !result?.deny) {
552 state.paneShown = false;
553 if (e.origin?.kind === 'person') { state.hidden = true; remember($, state); restart($, state); }
554 invalidate($, state);
555 }
556 return result;
557 });
558 on('command.*', async ($, e, next) => {
559 const session = await clearForeignView($);
560 if (!session || !next.is('command.run', e) || e.command !== 'delm:run') return next(e);
561 try {
562 const current = await readRun($, session);
563 if (e.args?.trim() && (!current || current.finished)) begin($, session, current?.ready?.run_id);
564 } catch {}
565 const result = await next(e);
566 try {
567 if (result.exitCode) failure($, session, result.text);
568 else { const run = await readRun($, session); if (run) observe($, run); }
569 } catch {}
570 return result;
571 });
572 on('command.run', {command: 'delm-status'}, async ($) => {
573 try { return await status($, await $.session.id()); }
574 catch { return {text: 'DeLM could not read this board. Your run controls remain available.'}; }
575 });
576 on('session.*', async ($, e, next) => {
577 const session = await clearForeignView($);
578 if (next.is('session.start', e)) {
579 const result = await next(e);
580 try {
581 const current = await $.session.id();
582 void restore($, current).catch(() => {});
583 } catch {}
584 return result;
585 }
586 if (next.is('session.end', e)) {
587 try { end($, e.sessionId || session); } catch {}
588 }
589 return next(e);
590 });
591 return {begin, observe, failure, restore, status, end};
592}
593hooks/board-render.js 475 lines1// Native presentation only. Rendering never reads files or calls the runtime.
2const BLUE = '#6484ed';
3const PHASES = {
4 preparing: 'Preparing', starting: 'Starting agents', running: 'Working', working: 'Working',
5 finishing: 'Finishing', stopping: 'Stopping', completed: 'Complete', complete: 'Complete',
6 stopped: 'Stopped', cancelled: 'Stopped', interrupted: 'Needs attention', failed: 'Needs attention',
7 error: 'Needs attention', attention: 'Needs attention', unknown: 'Status unavailable',
8 prepared: 'Starting agents', awaiting_shutdown: 'Finishing', verifying_shutdown: 'Finishing',
9 delivered: 'Changes applied', delivery_conflict: 'Delivery needs attention', recovery_required: 'Needs attention',
10 delivery_incomplete: 'Output delivery incomplete',
11 startup_failed: 'Could not start', preparation_failed: 'Could not prepare',
12};
13const segmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, {granularity: 'grapheme'}) : null;
14
15export function cleanText(value, limit = 4000) {
16 return String(value ?? '').replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, '')
17 .replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g, '')
18 .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '')
19 .replace(/[\r\t]/g, ' ').slice(0, limit);
20}
21
22function graphemes(value) {
23 const text = cleanText(value);
24 if (segmenter) return Array.from(segmenter.segment(text), part => part.segment);
25 const result = [];
26 for (const char of text) {
27 const previous = result[result.length - 1];
28 if (previous && (/^[\p{Mark}\p{Emoji_Modifier}\u200d\ufe0e\ufe0f]$/u.test(char) || previous.endsWith('\u200d')
29 || (/^\p{Regional_Indicator}$/u.test(previous) && /^\p{Regional_Indicator}$/u.test(char)))) result[result.length - 1] += char;
30 else result.push(char);
31 }
32 return result;
33}
34
35function graphemeCells(value) {
36 if (/^[\p{Mark}\u200d\ufe0e\ufe0f]+$/u.test(value)) return 0;
37 if (/\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20e3/u.test(value)) return 2;
38 const point = value.codePointAt(0);
39 return point >= 0x1100 && (point <= 0x115f || point === 0x2329 || point === 0x232a
40 || (point >= 0x2e80 && point <= 0xa4cf && point !== 0x303f)
41 || (point >= 0xac00 && point <= 0xd7a3) || (point >= 0xf900 && point <= 0xfaff)
42 || (point >= 0xfe10 && point <= 0xfe19) || (point >= 0xfe30 && point <= 0xfe6f)
43 || (point >= 0xff00 && point <= 0xff60) || (point >= 0xffe0 && point <= 0xffe6)
44 || (point >= 0x20000 && point <= 0x3fffd)) ? 2 : 1;
45}
46
47export function cellWidth(value) {
48 return graphemes(value).reduce((sum, item) => sum + graphemeCells(item), 0);
49}
50
51export function fitText(value, width) {
52 const text = cleanText(value).replace(/\n+/g, ' ');
53 const cells = Math.max(0, Math.floor(width));
54 if (!cells) return '';
55 if (cellWidth(text) <= cells) return text;
56 let result = '', used = 0;
57 for (const item of graphemes(text)) {
58 const size = graphemeCells(item);
59 if (used + size > cells - 1) break;
60 result += item; used += size;
61 }
62 return result + '…';
63}
64
65function collection(value) {
66 const items = Array.isArray(value) ? value : Array.isArray(value?.items) ? value.items : [];
67 return {...value, items, total: Math.max(items.length, Number(value?.total) || 0),
68 cursor: value?.cursor ?? value?.next_cursor ?? value?.next_offset ?? null};
69}
70
71function agentName(id, agents) {
72 if (id === null || id === undefined || id === '') return '';
73 const known = agents.find(agent => String(agent.id) === String(id));
74 if (known) return known.name;
75 const match = String(id).match(/^(?:worker[-_]|agent[-_])?(\d+)$/);
76 return match ? `Agent ${match[1]}` : cleanText(id, 120);
77}
78
79export function normalizeBoard(input = {}) {
80 const snapshot = input.view || input.snapshot || input;
81 const agents = (snapshot.agents || []).map((agent, index) => {
82 const native = input.nativeAgents?.find(item => (item.slot != null && item.slot === agent.slot)
83 || (item.id != null && item.id === agent.native_agent_id));
84 const observed = agent.nativeState ?? agent.native_state ?? 'unknown';
85 // A persisted runtime snapshot is ordered and includes admitted turns. The
86 // host cache is useful during startup, but has no comparable sequence and
87 // must never downgrade a newer durable observation to an old idle state.
88 const authoritative = Boolean(snapshot.source) || snapshot.finished
89 || ['stopped', 'waiting', 'blocked', 'waiting_for_dependency'].includes(observed);
90 return {
91 ...agent, id: agent.id ?? agent.worker_id ?? agent.slot ?? String(index + 1),
92 name: `Agent ${agent.slot ?? String(agent.id ?? agent.worker_id ?? '').match(/(\d+)$/)?.[1] ?? index + 1}`,
93 nativeState: authoritative ? (snapshot.finished ? 'stopped' : observed)
94 : native ? (native.status === 'running' ? (native.turn ? 'working' : 'ready') : native.status) : observed,
95 taskIds: agent.taskIds ?? agent.task_ids ?? agent.claimed_task_ids ?? [],
96 receivedRevision: native?.deliveredRevision ?? agent.receivedRevision ?? agent.received_revision ?? null,
97 status: agent.summary ?? agent.status,
98 }; });
99 const tasks = collection(snapshot.tasks);
100 tasks.items = tasks.items.map(task => ({...task, id: String(task.id ?? task.task_id),
101 title: cleanText(task.title ?? task.text ?? 'Untitled task'),
102 state: task.state ?? task.status ?? 'unknown', owner: task.owner ?? task.owner_id ?? task.claimed_by ?? null}));
103 const shared = collection(snapshot.shared ?? snapshot.shared_context);
104 shared.items = shared.items.map(entry => ({...entry, id: String(entry.id ?? entry.publication_id),
105 kind: entry.kind ?? 'finding', author: entry.author ?? entry.worker ?? entry.worker_id ?? entry.owner,
106 title: cleanText(entry.title ?? entry.summary ?? entry.text ?? 'Shared finding', 400),
107 text: cleanText(entry.text ?? entry.summary ?? ''), imports: entry.imports ?? entry.imported_by ?? [],
108 paths: entry.paths ?? entry.files ?? []}));
109 const selectedNative = input.nativeAgents?.find(agent => agent.id === input.selectedAgentId);
110 const selectedAgent = agents.find(agent => (input.selectedAgentId && agent.native_agent_id === input.selectedAgentId)
111 || (selectedNative && agent.slot === selectedNative.slot));
112 return {...snapshot, agents, tasks, shared, checks: collection(snapshot.checks), outcome: snapshot.outcome || {},
113 freshness: snapshot.freshness || {}, phase: input.phase || snapshot.phase || snapshot.status || 'preparing',
114 runId: snapshot.runId ?? snapshot.run_id, requestRevision: snapshot.requestRevision ?? snapshot.request_revision ?? snapshot.revision,
115 attention: snapshot.attention || input.attention, disconnected: input.disconnected || snapshot.freshness?.disconnected,
116 viewError: input.disconnected && input.viewError ? cleanText(input.viewError, 600) : null,
117 selectedAgentName: selectedAgent?.name,
118 canRetryFinish: Boolean(input.canRetryFinish && !snapshot.finished), retryingFinish: Boolean(input.retryingFinish),
119 conversationAvailable: Boolean(input.conversationAvailable)};
120}
121
122export function phaseLabel(view) {
123 const outcome = view.outcome || {};
124 if ((outcome.undelivered_outputs_total > 0 || outcome.undelivered_outputs?.length)
125 && !(outcome.artifacts_declared && outcome.delivered)) return 'Output delivery incomplete';
126 if (outcome.verificationRequired || outcome.verification_required) return 'Local verification required';
127 if (outcome.conflict || outcome.delivery_conflict || outcome.conflicts?.length) return 'Delivery needs attention';
128 if (view.phase === 'stopped' && outcome.recovery_saved) return 'Stopped · changes saved';
129 return PHASES[view.phase] || 'Status unavailable';
130}
131
132function taskNumber(id) { return String(id).startsWith('#') ? String(id) : `#${id}`; }
133function taskLabel(task) { return taskNumber(task.task_number ?? task.id); }
134
135function taskState(task, view) {
136 const owner = agentName(task.owner, view.agents);
137 const label = {available: 'Available', open: 'Available', claimed: 'Claimed', done: 'Done', completed: 'Done'}[task.state] || 'State unavailable';
138 return owner ? `${label} · ${owner}` : label;
139}
140
141function agentState(agent) {
142 const native = {running: 'Working', working: 'Working', active: 'Working', starting: 'Starting',
143 waiting: 'Waiting', blocked: 'Waiting', ready: 'Ready', waiting_for_dependency: 'Waiting', waiting_for_user: 'Waiting for you',
144 completed: 'Turn complete', stopped: 'Stopped', ended: 'Turn complete', failed: 'Needs attention'}[agent.nativeState];
145 return native || (agent.taskIds?.length ? 'Claimed' : 'Status unavailable');
146}
147
148function compactSummary(view) {
149 if (view.disconnected) return `${phaseLabel(view)} · Updates disconnected`;
150 const phase = phaseLabel(view);
151 if (!['running', 'working'].includes(view.phase)) return phase;
152 if (view.freshness.unavailable?.includes('board')) return `${phase} · Board updates unavailable`;
153 const active = view.agents.filter(agent => ['running', 'working', 'active'].includes(agent.nativeState)).length;
154 if (!active) return `${phase} · ${view.tasks.total} tasks`;
155 const completeCounts = view.agents.length && view.agents.every(agent => Number.isSafeInteger(agent.task_count) && agent.task_count >= 0);
156 const claimed = completeCounts ? view.agents.reduce((total, agent) => total + agent.task_count, 0)
157 : view.tasks.items.filter(task => task.state === 'claimed').length;
158 if (!completeCounts && view.tasks.total > view.tasks.items.length) return `${active} ${active === 1 ? 'agent' : 'agents'} working · ${view.tasks.total} tasks`;
159 return `${active} ${active === 1 ? 'agent' : 'agents'} working · ${claimed} claimed`;
160}
161
162export function boardSummary(state) {
163 const view = normalizeBoard(state);
164 const lines = [`DeLM · ${compactSummary(view)}`];
165 if (view.attention) lines.push(cleanText(view.attention));
166 for (const agent of view.agents) lines.push(`${agent.name} · ${agentState(agent)}${agent.taskIds?.length ? ` · ${agent.taskIds.map(id => {
167 const task = view.tasks.items.find(item => String(item.id) === String(id));
168 return task ? taskLabel(task) : `Task ID ${id}`;
169 }).join(', ')}` : ''}`);
170 if (view.outcome.verificationRequired || view.outcome.verification_required) lines.push('See the final handoff for local verification.');
171 return lines.join('\n');
172}
173
174function helpers(components, e, actions) {
175 const {Box, Text, Button} = components;
176 const width = Math.max(1, Math.floor(Number(e.props?.bodyColumns) || 52));
177 const padding = width > 24 ? 1 : 0;
178 const inner = Math.max(1, width - padding * 2);
179 const text = (value, props = {}) => Text({...props, children: cleanText(value)});
180 const line = (value, props = {}) => text(fitText(value, inner), props);
181 const row = (children, props = {}) => Box({flexDirection: 'row', ...props, children});
182 const column = (children, props = {}) => Box({flexDirection: 'column', ...props, children});
183 const button = (key, label, callback, props = {}) => Button({key, label: fitText(label, inner), plain: true, onPress: callback || (() => {}), ...props});
184 const heading = label => text(label, {bold: true});
185 const blank = () => text(' ');
186 const controls = back => (inner < 22 ? column : row)([
187 button(back ? 'board-back' : 'board-details', back ? 'Back' : 'Details', back ? actions.back : actions.details),
188 ...(inner < 22 ? [] : [text(' ')]), button('board-hide', 'Hide board', actions.hide),
189 ]);
190 const retryFinish = view => button('board-retry-finish', view.retryingFinish ? 'Retrying finishing…' : 'Retry finishing',
191 view.retryingFinish ? undefined : actions.retryFinish);
192 return {Box, Text, Button, width, inner, padding, bodyRows: Number(e.props?.scroll?.bodyRows) || 24,
193 text, line, row, column, button, heading, blank, controls, retryFinish};
194}
195
196function statusLines(h, view) {
197 const lines = [h.row([h.text('DeLM', {bold: true, color: BLUE}), h.text(` · ${phaseLabel(view)}`)])];
198 if (view.selectedAgentName) lines.push(h.line(`Viewing ${view.selectedAgentName}`, {dimColor: true}));
199 if (view.disconnected) lines.push(h.line('Updates disconnected · showing last known state', {dimColor: true}));
200 else if (view.freshness.unavailableSources?.length || view.freshness.unavailable_sources?.length || view.freshness.unavailable?.length) lines.push(h.line('Some updates are temporarily unavailable', {dimColor: true}));
201 if (view.attention) lines.push(h.text(view.attention));
202 if (view.conversationAvailable && view.attention) lines.push(h.line('You can continue chatting here.', {dimColor: true}));
203 if (view.canRetryFinish) lines.push(h.retryFinish(view));
204 return lines;
205}
206
207function taskRow(h, task, view, actions) {
208 return h.column([
209 h.button(`task-${task.id}`, `${taskLabel(task)} ${task.title}`, () => actions.select?.('task', task.id)),
210 h.line(` ${taskState(task, view)}`, {dimColor: true}),
211 ], {key: `task-row-${task.id}`});
212}
213
214function sharedRow(h, entry, view, actions) {
215 const author = agentName(entry.author, view.agents) || 'Agent';
216 const imported = entry.imports.map(item => agentName(typeof item === 'object' ? item.worker_id ?? item.agent_id ?? item.id : item, view.agents)).filter(Boolean);
217 return h.column([
218 h.button(`shared-${entry.id}`, entry.title, () => actions.select?.('contribution', entry.id)),
219 h.line(`${author} · ${entry.kind === 'publication' ? 'Code shared' : 'Finding shared'}${imported.length ? ` · Imported by ${imported.join(', ')}` : ''}`, {dimColor: true}),
220 ], {key: `shared-row-${entry.id}`});
221}
222
223function more(h, type, shown, collection, actions) {
224 if (collection.total <= shown && !collection.cursor) return [];
225 return [h.row([h.line(`Showing ${shown} of ${collection.total}`, {dimColor: true}), h.text(' '),
226 h.button(`view-all-${type}`, 'View all', () => actions.select?.(type, null))])];
227}
228
229function emptyCollection(view, kind) {
230 const unavailable = view.freshness.unavailable || view.freshness.unavailableSources || view.freshness.unavailable_sources || [];
231 if (unavailable.includes('board')) {
232 if (!view.source && view.disconnected) return kind === 'tasks' ? 'Tasks unavailable' : 'Shared context unavailable';
233 if (!view.source) return kind === 'tasks' ? 'Loading tasks…' : 'Loading shared context…';
234 return kind === 'tasks' ? 'Tasks temporarily unavailable' : 'Shared context temporarily unavailable';
235 }
236 return kind === 'tasks' ? 'No tasks shared yet' : 'No findings or code shared yet';
237}
238
239function tinyOverview(h, view, actions) {
240 const rows = Math.max(1, Math.floor(h.bodyRows));
241 const phase = rows < 5 ? compactSummary(view) : phaseLabel(view);
242 const extra = view.disconnected && rows >= 5 ? ' · Updates disconnected' : view.attention ? ' · See details' : '';
243 const children = [h.row([h.text('DeLM', {bold: true, color: BLUE}), h.text(fitText(` · ${phase}${extra}`, Math.max(0, h.inner - 4)))])];
244 if (rows >= 6 && view.agents.length <= 2) {
245 for (const agent of view.agents) {
246 const task = view.tasks.items.find(item => agent.taskIds.map(String).includes(String(item.id)));
247 children.push(h.line(`${agent.name} · ${agentState(agent)}${task ? ` · ${taskLabel(task)} ${task.title}` : ''}`));
248 }
249 if (!view.agents.length) children.push(h.line('Waiting for agents to start', {dimColor: true}));
250 } else if (rows >= 5) children.push(h.line(view.agents.length
251 ? view.agents.map(agent => `${agent.name} · ${agentState(agent)}`).join(' ')
252 : 'Waiting for agents to start'));
253 const unavailable = view.freshness.unavailable?.includes('board');
254 const knownCount = view.agents.length && view.agents.every(agent => Number.isSafeInteger(agent.task_count) && agent.task_count >= 0);
255 const claimed = knownCount ? view.agents.reduce((total, agent) => total + agent.task_count, 0)
256 : view.tasks.total === view.tasks.items.length ? view.tasks.items.filter(task => task.state === 'claimed').length : null;
257 const tasks = unavailable ? 'Updates unavailable' : `${view.tasks.total} tasks${claimed != null ? ` · ${claimed} claimed` : ''}`;
258 const shared = unavailable ? 'Updates unavailable' : `${view.shared.total} shared`;
259 if (rows >= 4) {
260 children.push(h.button('view-all-tasks', `Task queue · ${tasks}`, () => actions.select?.('tasks', null)),
261 h.button('view-all-shared', `Shared context · ${shared}`, () => actions.select?.('shared', null)));
262 } else if (rows >= 3) children.push(h.button('view-all-tasks', `Task queue · ${tasks}`, () => actions.select?.('tasks', null)));
263 if (rows >= 2) children.push(view.canRetryFinish
264 ? h.row([h.retryFinish(view), ...(h.inner >= (view.retryingFinish ? 28 : 24)
265 ? [h.text(' '), h.button('board-details', 'Details', actions.details)] : [])]) : h.controls(false));
266 else children[0] = h.button('board-details', `DeLM · ${phase} · Details`, actions.details);
267 return children;
268}
269
270function shortOverview(h, view, actions) {
271 const children = statusLines(h, view);
272 const spacious = h.bodyRows >= 16;
273 if (spacious) children.push(h.blank(), h.heading('Agents'));
274 for (const agent of view.agents) {
275 const task = view.tasks.items.find(item => agent.taskIds.map(String).includes(String(item.id)));
276 children.push(h.line(`${agent.name} · ${agentState(agent)}${task ? ` · ${taskLabel(task)} ${task.title}` : ''}`));
277 }
278 if (!view.agents.length) children.push(h.line('Waiting for agents to start', {dimColor: true}));
279 const slots = Math.max(1, Math.min(4, h.bodyRows - children.length - (spacious ? 7 : 4)));
280 const ordered = [...view.tasks.items.filter(task => !['done', 'completed'].includes(task.state)),
281 ...view.tasks.items.filter(task => ['done', 'completed'].includes(task.state))];
282 const tasks = ordered.slice(0, slots);
283 const taskHeading = view.tasks.total > tasks.length ? `Task queue · ${tasks.length} of ${view.tasks.total} · View all` : `Task queue · ${view.tasks.total}`;
284 if (spacious) children.push(h.blank());
285 children.push(h.button('view-all-tasks', taskHeading, () => actions.select?.('tasks', null)));
286 if (!tasks.length) children.push(h.line(emptyCollection(view, 'tasks'), {dimColor: true}));
287 for (const task of tasks) {
288 const tail = ` · ${taskState(task, view)}`, prefix = `${taskLabel(task)} `;
289 const title = fitText(task.title, Math.max(1, h.inner - cellWidth(prefix + tail)));
290 children.push(h.button(`task-${task.id}`, `${prefix}${title}${tail}`, () => actions.select?.('task', task.id)));
291 }
292 const shared = view.shared.items[0];
293 if (spacious) children.push(h.blank());
294 children.push(h.button('view-all-shared', view.shared.total > 1 ? `Shared context · 1 of ${view.shared.total} · View all` : 'Shared context', () => actions.select?.('shared', null)));
295 if (!shared) children.push(h.line(emptyCollection(view, 'shared'), {dimColor: true}));
296 else {
297 const imports = shared.imports.map(item => agentName(typeof item === 'object' ? item.worker_id ?? item.agent_id ?? item.id : item, view.agents)).filter(Boolean);
298 const tail = ` · ${agentName(shared.author, view.agents)}${imports.length ? ` · Imported by ${imports.join(', ')}` : ''}`;
299 children.push(h.button(`shared-${shared.id}`, `${fitText(shared.title, Math.max(1, h.inner - cellWidth(tail)))}${tail}`,
300 () => actions.select?.('contribution', shared.id)));
301 }
302 if (spacious) children.push(h.blank());
303 children.push(h.controls(false));
304 return children;
305}
306
307function overview(h, view, actions) {
308 if (h.bodyRows < 8) return tinyOverview(h, view, actions);
309 if (h.bodyRows < 23) return shortOverview(h, view, actions);
310 const children = [...statusLines(h, view), h.blank(), h.heading('Agents')];
311 if (!view.agents.length) children.push(h.line('Waiting for agents to start', {dimColor: true}));
312 for (const agent of view.agents) {
313 const current = view.tasks.items.find(task => agent.taskIds.map(String).includes(String(task.id)));
314 children.push(h.line(`${agent.name} · ${agentState(agent)}`, {bold: true}));
315 if (current) children.push(h.line(`${taskLabel(current)} ${current.title}`, {dimColor: true}));
316 else if (agent.dependency) children.push(h.line(agent.dependency, {dimColor: true}));
317 else if (agent.status && typeof agent.status === 'string') children.push(h.line(`Reported: ${agent.status}`, {dimColor: true}));
318 if (current && agent.dependency && ['waiting', 'blocked', 'waiting_for_dependency'].includes(agent.nativeState)) children.push(h.line(agent.dependency, {dimColor: true}));
319 }
320 children.push(h.blank(), h.heading(`Task queue · ${view.tasks.total}`));
321 const tasks = [...view.tasks.items.filter(task => !['done', 'completed'].includes(task.state)),
322 ...view.tasks.items.filter(task => ['done', 'completed'].includes(task.state))].slice(0, 4);
323 if (!tasks.length) children.push(h.line(emptyCollection(view, 'tasks'), {dimColor: true}));
324 for (const task of tasks) children.push(taskRow(h, task, view, actions));
325 children.push(...more(h, 'tasks', tasks.length, view.tasks, actions));
326 children.push(h.blank(), h.heading('Shared context'));
327 const shared = view.shared.items.slice(0, 2);
328 if (!shared.length) children.push(h.line(emptyCollection(view, 'shared'), {dimColor: true}));
329 for (const entry of shared) children.push(sharedRow(h, entry, view, actions));
330 children.push(...more(h, 'shared', shared.length, view.shared, actions), h.blank(), h.controls(false));
331 return children;
332}
333
334function outcomeLines(h, outcome) {
335 const children = [];
336 const additionalReview = outcome.artifacts_declared && outcome.delivered;
337 if (outcome.delivered || outcome.deliveryApplied || outcome.delivery_applied) children.push(h.text('Changes applied to your project.'));
338 if (outcome.undelivered_outputs_total > 0 || outcome.undelivered_outputs?.length) {
339 children.push(h.text(additionalReview ? 'Additional files saved for review.'
340 : 'Some generated outputs were preserved for recovery and were not delivered to your project.'),
341 ...(outcome.undelivered_outputs || []).map(path => h.text(path, {dimColor: true})));
342 if (outcome.undelivered_outputs_total > outcome.undelivered_outputs?.length) children.push(h.text(
343 `${outcome.undelivered_outputs_total} ${additionalReview ? 'additional files saved in total' : 'outputs need recovery in total'}.`, {dimColor: true}));
344 }
345 if (outcome.artifacts?.length) {
346 children.push(h.heading(outcome.delivered ? 'Delivered generated outputs' : 'Selected generated outputs'),
347 ...outcome.artifacts.map(path => h.text(path, {dimColor: true})));
348 if (outcome.artifacts_total > outcome.artifacts.length) children.push(h.text(`${outcome.artifacts_total} generated outputs in total.`, {dimColor: true}));
349 }
350 if (outcome.verificationRequired || outcome.verification_required) children.push(h.text('Local verification required. See Claude’s final handoff for the outcome.'));
351 if (outcome.cleanupComplete || outcome.cleanup_complete) children.push(h.text('Temporary workspaces removed.'));
352 if (outcome.recoveryPath || outcome.recovery_path) children.push(h.text(
353 outcome.undelivered_outputs_total > 0 || outcome.undelivered_outputs?.length
354 ? additionalReview ? 'Review the additional files in this saved bundle.' : 'Additional outputs saved for recovery. Source changes may already be applied.'
355 : 'Unfinished changes saved for recovery. They were not automatically applied to your project.'),
356 h.text(outcome.recoveryPath || outcome.recovery_path, {dimColor: true}));
357 if (outcome.retainedPath || outcome.retained_path || outcome.retained_workspace_path) children.push(h.text('Temporary workspaces retained because safe cleanup was not confirmed.'), h.text(outcome.retainedPath || outcome.retained_path || outcome.retained_workspace_path, {dimColor: true}));
358 if (outcome.error || outcome.reason) children.push(h.text(outcome.error || outcome.reason));
359 if (outcome.conflict || outcome.delivery_conflict || outcome.conflicts?.length) {
360 children.push(h.text('Delivery needs attention. See the final handoff before applying changes.'), ...(outcome.conflicts || []).map(path => h.text(path, {dimColor: true})));
361 if (outcome.conflicts_total > outcome.conflicts?.length) children.push(h.text(`Showing ${outcome.conflicts.length} of ${outcome.conflicts_total} conflicts.`, {dimColor: true}));
362 }
363 if (Array.isArray(outcome.excluded_paths) && outcome.excluded_paths.length) {
364 children.push(h.heading('Excluded paths'),
365 ...outcome.excluded_paths.map(item => h.text(`${item.path} · ${item.reason}`, {dimColor: true})));
366 if (outcome.excluded_paths_total > outcome.excluded_paths.length) children.push(h.text(`${outcome.excluded_paths_total} exclusions in total.`, {dimColor: true}));
367 }
368 return children.length ? children : [h.text('No final result yet.', {dimColor: true})];
369}
370
371function detail(h, view, screen, actions) {
372 const children = [...statusLines(h, view), h.blank()];
373 const kind = screen.kind;
374 if (view.detailStale) children.push(h.row([h.text('New updates · ', {dimColor: true}), h.button('detail-refresh', 'Refresh', actions.refresh)]));
375 if (view.detailRevision != null && view.detailRevision !== view.requestRevision) children.push(h.text(`Showing request revision ${view.detailRevision}`, {dimColor: true}));
376 if (view.detailLoading) children.push(h.text('Loading records…', {dimColor: true}));
377 if (view.detailError) children.push(h.text(view.detailError));
378 if (kind === 'task') {
379 const task = (screen.item ? normalizeBoard({tasks: [screen.item]}).tasks.items[0] : null)
380 || view.tasks.items.find(item => String(item.id) === String(screen.id));
381 children.push(h.heading('Task detail'));
382 if (!task) children.push(h.text('This task is not available in the current snapshot.', {dimColor: true}));
383 else {
384 children.push(h.text(`${taskLabel(task)} ${task.title}`, {bold: true}), h.text(taskState(task, view)), h.blank());
385 if (task.description || task.body) children.push(h.text(task.description || task.body));
386 const labels = {implementation: 'Implementation', verification: 'Verification', integration: 'Integration'};
387 if (task.kind) children.push(h.text(`Type: ${labels[task.kind] || cleanText(task.kind)}`, {dimColor: true}));
388 for (const [field, label] of [['interface', 'Interface'], ['done_when', 'Completion condition'], ['handoff', 'Handoff']]) {
389 if (task[field]) children.push(h.blank(), h.heading(label), h.text(task[field]));
390 }
391 if (task.dependencies?.length) {
392 children.push(h.blank(), h.heading('Shared contributions used'));
393 for (const id of task.dependencies) children.push(h.button(`task-publication-${id}`, `Contribution ${id}`,
394 () => actions.select?.('contribution', id)));
395 }
396 if (task.state === 'done' || task.state === 'completed') children.push(h.text('Marked done by the agent. Recorded checks are listed separately.', {dimColor: true}));
397 if (task.version != null) children.push(h.text(`Task version ${task.version}`, {dimColor: true}));
398 if (task.task_number != null && String(task.task_number) !== String(task.id)) children.push(h.text(`Board task ID ${task.id}`, {dimColor: true}));
399 }
400 } else if (kind === 'contribution' || (kind === 'shared' && screen.id != null)) {
401 const entry = (screen.item ? normalizeBoard({shared: [screen.item]}).shared.items[0] : null)
402 || view.shared.items.find(item => String(item.id) === String(screen.id));
403 children.push(h.heading('Shared context detail'));
404 if (!entry) children.push(h.text('This contribution is not available in the current snapshot.', {dimColor: true}));
405 else {
406 children.push(h.text(entry.title, {bold: true}), h.text(`${agentName(entry.author, view.agents)} · ${entry.kind === 'publication' ? 'Code shared' : 'Finding shared'}`, {dimColor: true}), h.blank());
407 if (entry.text && entry.text !== entry.title) children.push(h.text(entry.text));
408 if (entry.paths?.length) children.push(h.blank(), h.heading(entry.file_count > entry.paths.length
409 ? `Shared files · ${entry.paths.length} of ${entry.file_count}` : 'Shared files'), ...entry.paths.map(path => h.text(path)));
410 if (entry.imports.length) children.push(h.blank(), h.text(`Imported by ${entry.imports.map(item => agentName(typeof item === 'object' ? item.worker_id ?? item.agent_id ?? item.id : item, view.agents)).join(', ')}`));
411 else if (entry.kind === 'publication') children.push(h.text('No peer import confirmed.', {dimColor: true}));
412 }
413 } else if (kind === 'tasks' || kind === 'shared' || kind === 'checks') {
414 const records = view[kind];
415 children.push(h.heading({tasks: 'All tasks', shared: 'Shared context', checks: 'Recorded checks'}[kind]));
416 if (!records.items.length && !view.detailLoading && !view.detailError) children.push(h.text(
417 view.freshness.unavailable?.includes('board') ? 'Records temporarily unavailable.' : 'No records yet.', {dimColor: true}));
418 for (const record of records.items) {
419 if (kind === 'tasks') children.push(taskRow(h, record, view, actions));
420 else if (kind === 'shared') children.push(sharedRow(h, record, view, actions));
421 else {
422 children.push(h.text(record.title || record.name || record.summary || record.scope || 'Recorded check', {bold: true}),
423 h.text(`${cleanText(record.outcome || record.status || (record.passed === true ? 'Recorded pass' : record.passed === false ? 'Recorded failure' : 'Outcome unavailable'))}${record.valid_for_reuse === false || record.reusable === false ? ' · Not valid for reuse' : ''}`, {dimColor: true}));
424 const author = agentName(record.worker, view.agents);
425 if (author || record.revision != null) children.push(h.text([author, record.revision == null ? '' : `Request revision ${record.revision}`].filter(Boolean).join(' · '), {dimColor: true}));
426 if (record.reusable == null) children.push(h.text('Current reuse validity not checked by this view.', {dimColor: true}));
427 }
428 }
429 if (view.detailError) children.push(h.button(`retry-${kind}`, 'Try again', () => actions.page?.(kind, records.offset || 0)));
430 if (records.cursor) children.push(h.blank(), h.button(`next-${kind}`, 'Next page', () => actions.page?.(kind, records.cursor)));
431 const previous = screen.previousCursor ?? records.previous_offset ?? (records.offset > 0 ? Math.max(0, records.offset - (records.limit || records.items.length || 8)) : null);
432 if (previous != null) children.push(h.button(`previous-${kind}`, 'Previous page', () => actions.page?.(kind, previous)));
433 if (records.total > records.items.length) children.push(h.line(`${records.items.length} shown · ${records.total} total`, {dimColor: true}));
434 } else {
435 children.push(h.heading('Run details'), h.line(`Run ${view.runId || 'preparing'}`, {dimColor: true}));
436 const observedDate = new Date(view.observed_at);
437 if (Number.isFinite(view.observed_at) && Number.isFinite(observedDate.getTime())) children.push(h.text(`Last observed ${observedDate.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC')}`, {dimColor: true}));
438 if (view.disconnected) children.push(h.text('Updates disconnected. These are the last observed facts.', {dimColor: true}));
439 if (view.viewError) children.push(h.text(`Reason: ${view.viewError}`));
440 if (view.requestRevision != null) children.push(h.text(`Accepted request revision ${view.requestRevision}`, {dimColor: true}));
441 for (const agent of view.agents) if (agent.receivedRevision != null) children.push(h.text(`${agent.name} received revision ${agent.receivedRevision}`, {dimColor: true}));
442 children.push(h.blank(), h.button('detail-tasks', `All tasks (${view.tasks.total})`, () => actions.select?.('tasks', null)),
443 h.button('detail-shared', `Shared context (${view.shared.total})`, () => actions.select?.('shared', null)),
444 h.button('detail-checks', `Recorded checks (${view.checks.total})`, () => actions.select?.('checks', null)),
445 h.blank(), h.heading('Result'), ...outcomeLines(h, view.outcome));
446 }
447 children.push(h.blank(), h.controls(true));
448 return children;
449}
450
451export function renderBoard(components, e, state = {}, actions = {}) {
452 const h = helpers(components, e, actions), view = normalizeBoard(state);
453 view.detailLoading = state.detailLoading;
454 view.detailError = state.detailError;
455 view.detailStale = state.detailStale;
456 view.detailRevision = state.detailRevision;
457 const screen = state.screen || {kind: 'overview'};
458 if (state.pages?.[screen.kind]) {
459 const page = state.pages[screen.kind];
460 view[screen.kind] = normalizeBoard({[screen.kind]: page})[screen.kind];
461 }
462 return h.column(screen.kind === 'overview' ? overview(h, view, actions) : detail(h, view, screen, actions),
463 {width: h.width, paddingX: h.padding});
464}
465
466export function renderCompact(components, e, state = {}, actions = {}) {
467 if (e.props?.hasSurvey || Number(e.props?.maxRows) < 1 || state.paneShown || state.suppressCompact) return null;
468 const h = helpers(components, e, actions), view = normalizeBoard(state);
469 const summary = `DeLM · ${compactSummary(view)}`;
470 if (Number(e.props?.maxRows) === 1) {
471 return h.button('board-show', fitText(`${summary} · Show board`, h.width), actions.show);
472 }
473 return h.column([h.text(fitText(summary, h.width)), h.button('board-show', 'Show board', actions.show)], {width: h.width});
474}
475