SLOPSHOPPER

delm

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

panebandrowsguardcommandprompt
★ 46v0.3.1MITupdated 2026-10-07Decentralized-LM/delm-agent-plugin/hosts/claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delm
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /delm-status ⎿ delm: No DeLM run in this conversation. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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> &nbsp; <a href="https://yuzhenmao.github.io/DeLM/"><img src="docs/assets/website.svg" alt="Visit the project website" height="28"></a> &nbsp; <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.

  • Work in parallel. Agents claim tasks and pick up new work as it becomes available.
  • Share progress. Findings, files, and relevant checks are available to the other agents as they work.
  • Keep your workflow. Start a run in your usual conversation, send clarifications there, and receive the changes in your original project.

DeLM uses your existing host account and starts agents only when you ask.

Install on macOS

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.

HostAfter installation
CodexRestart, open /hooks, and review and trust the DeLM hooks. Restart once more to load them.
Claude CodeRestart 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.

Run a task

Open your host in the project you want to change, then invoke DeLM with a request:

HostStart 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.

Your host setup

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.

Documentation

GuideContents
SupportRequirements, permissions, timing reports, updates, and recovery
ArchitectureHost adapters, shared coordination, and project delivery
ContributingDevelopment setup and verification
Release guideBuild qualification and distribution
Claude integrationNative integration design and validation

Research

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.

Contributors

Thanks to all contributors:

<a href="https://github.com/jerry2247"><img src="https://avatars.githubusercontent.com/u/88354083?v=4&amp;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&amp;s=128" width="64" height="64" alt="ZachDeLong" title="ZachDeLong"></a>

License

DeLM is licensed under MIT. Third-party components retain their own licenses; see NOTICE.

Source 4 files
hooks/delm.js 1065 lines
1import {
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}
1065
hooks/protocol.js 116 lines
1export 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}
116
hooks/board-view.js 593 lines
1import {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}
593
hooks/board-render.js 475 lines
1// 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