@chatpanel/events 0.83.1 → 0.84.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -197,6 +197,8 @@ export { fixedPlan, parsePlan, plannerPrompt, waves, breakCycles, TEAM_PLAN_SCHE
197
197
  export { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, FINDINGS_SCHEMA, FINDING_KINDS, THREAD_KINDS, THREAD_STATUSES, POST_KINDS, POST_STATUSES, ASK_TYPES, emptyBoardState, foldBoard, findingsOf } from './team-board.js';
198
198
  export { boardToolProvider, boardToolSpec, createAnswerBox, withBoardTool, BOARD_TOOL_NAME, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
199
199
  export { createRunCache, withRunCache } from './team-cache.js';
200
+ export { emptyRun, foldRun, runFromEvents, checkpointFrom, isResumable, LIVE_RUN_STATUSES, RESUMABLE_RUN_STATUSES } from './team-record.js';
201
+ export { messagesFor, mergeTranscript, clipTranscript, clipMessage, newSteps, continuationNote, createControl, STEP_MAX_CHARS, TASK_TRANSCRIPT_MAX_CHARS } from './team-task.js';
200
202
  export { runTeam, resumeTeam, dryRunTeam, isModelUnavailable, TeamRunError, RUN_STATUSES } from './team-run.js';
201
203
  export { teamToolProvider, teamToolSpec, describeTeamForApproval, TEAM_TOOL_NAME } from './team-tool.js';
202
204
  export { teamLine, teamLanes } from './team-trail.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.83.1",
3
+ "version": "0.84.1",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -92,6 +92,8 @@
92
92
  "./team-board.js": "./team-board.js",
93
93
  "./board-tool.js": "./board-tool.js",
94
94
  "./team-cache.js": "./team-cache.js",
95
+ "./team-task.js": "./team-task.js",
96
+ "./team-record.js": "./team-record.js",
95
97
  "./team-plan.js": "./team-plan.js",
96
98
  "./team-run.js": "./team-run.js",
97
99
  "./team-tool.js": "./team-tool.js",
@@ -209,6 +211,8 @@
209
211
  "team-board.js",
210
212
  "board-tool.js",
211
213
  "team-cache.js",
214
+ "team-task.js",
215
+ "team-record.js",
212
216
  "team-plan.js",
213
217
  "team-run.js",
214
218
  "team-tool.js",
package/team-record.js ADDED
@@ -0,0 +1,102 @@
1
+ // The run record — a team run folded from its events, the same way on the gateway's store
2
+ // and in either client. One fold, so what the desktop shows, what the extension shows and
3
+ // what the store holds never disagree; and enough on the record to RESUME the run from
4
+ // anywhere: the plan, every task's status and transcript, the board, the spend.
5
+ //
6
+ // The gateway vendors this file (its store used to carry a mirror of it, which is the copy
7
+ // that drifts). `checkpointFrom(run)` is what a client hands `resumeTeam` after reading a
8
+ // run the process that started it no longer runs.
9
+
10
+ import { foldBoard, emptyBoardState } from './team-board.js';
11
+
12
+ export const LIVE_RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting']);
13
+ export const RESUMABLE_RUN_STATUSES = Object.freeze(['waiting', 'stopped', 'failed', 'partial', 'over-budget', 'answered']);
14
+ const TASK_TEXT_MAX = 20_000;
15
+
16
+ export function emptyRun({ id, client = '', now = Date.now() } = {}) {
17
+ return { id, client: String(client || '').slice(0, 40), createdAt: now, lastEventAt: now, status: 'planning', team: '', request: '', roles: [], plan: null, tasks: [], board: [], threads: emptyBoardState(), checkpoint: null, proposal: null, usage: null, stopRequested: null, startedAt: null, endedAt: null };
18
+ }
19
+
20
+ const taskOf = (run, id) => run.tasks.find((x) => x.id === id);
21
+
22
+ /** Fold one event (`{ type, at, payload }`, or a flat `{ type, at, ...payload }`) into the record. */
23
+ export function foldRun(run, ev) {
24
+ const type = String(ev?.type || '');
25
+ const p = ev?.payload && typeof ev.payload === 'object' ? ev.payload : (ev || {});
26
+ const at = Number(ev?.at) || Date.now();
27
+ run.lastEventAt = at;
28
+ switch (type) {
29
+ case 'run.started':
30
+ case 'run.resumed':
31
+ run.team = p.team || run.team; run.request = p.request ?? run.request; run.budget = p.budget || run.budget;
32
+ run.roles = Array.isArray(p.roles) ? p.roles : run.roles; run.status = 'planning'; run.startedAt = run.startedAt || at;
33
+ if (type === 'run.resumed') { run.endedAt = null; run.checkpoint = null; run.resumedAt = at; }
34
+ break;
35
+ case 'plan.ready':
36
+ run.plan = { by: p.by || 'fixed', tasks: Array.isArray(p.tasks) ? p.tasks : [] };
37
+ // A resume replays the plan: keep what the tasks already hold (transcripts, attempts).
38
+ run.tasks = run.plan.tasks.map((t) => ({ ...(taskOf(run, t.id) || {}), id: t.id, role: t.role, title: t.title, status: taskOf(run, t.id)?.status === 'ok' ? 'ok' : 'pending', findings: taskOf(run, t.id)?.findings || 0 }));
39
+ run.status = 'running';
40
+ break;
41
+ case 'task.started': { const t = taskOf(run, p.taskId); if (t) { t.status = 'running'; t.startedAt = at; t.error = null; } run.status = 'running'; break; }
42
+ case 'task.model': { const t = taskOf(run, p.taskId); if (t) { t.model = p.model; t.attempts = [...(t.attempts || []), { model: p.model, at, attempt: p.attempt }]; } break; }
43
+ case 'task.step': { const t = taskOf(run, p.taskId); if (t && Array.isArray(p.steps)) t.transcript = [...(t.transcript || []), ...p.steps]; break; }
44
+ case 'task.handoff': { const t = taskOf(run, p.taskId); if (t) { t.model = p.to; t.handoffs = [...(t.handoffs || []), { from: p.from, to: p.to, by: p.by, reason: p.reason, at }]; } break; }
45
+ case 'task.tool': { const t = taskOf(run, p.taskId); if (t) t.tools = (t.tools || 0) + 1; break; }
46
+ case 'task.delta': { const t = taskOf(run, p.taskId); if (t) t.text = String(p.text || '').slice(0, TASK_TEXT_MAX); break; }
47
+ case 'task.finding':
48
+ if (p.finding && p.finding.text) { run.board.push({ ...p.finding, at }); const t = taskOf(run, p.taskId); if (t) t.findings += 1; }
49
+ break;
50
+ case 'task.waiting': { const t = taskOf(run, p.taskId); if (t) { t.status = 'waiting'; t.waitingOn = p.threadId; } break; }
51
+ case 'task.done':
52
+ case 'task.failed': { const t = taskOf(run, p.taskId); if (t) { t.status = p.status || (type === 'task.done' ? 'ok' : 'failed'); t.error = p.error || null; t.ms = p.ms; t.endedAt = at; } break; }
53
+ case 'run.merging': run.status = 'merging'; break;
54
+ case 'run.waiting': run.status = 'running'; break;
55
+ case 'run.usage': run.usage = p.usage || run.usage; break;
56
+ case 'run.done':
57
+ run.status = p.status || 'completed'; run.usage = p.usage || run.usage; run.proposal = p.proposal ?? run.proposal; run.endedAt = at;
58
+ if (p.checkpoint) run.checkpoint = p.checkpoint;
59
+ break;
60
+ case 'run.stop-requested': run.stopRequested = at; break;
61
+ case 'board.thread': case 'board.post': case 'board.decision': case 'board.thread-status':
62
+ run.threads = foldBoard(run.threads || emptyBoardState(), ev);
63
+ if (type === 'board.thread-status' && p.status !== 'waiting' && run.status === 'waiting' && !(run.threads.threads || []).some((t) => t.kind === 'ask' && t.status === 'waiting')) run.status = 'answered';
64
+ break;
65
+ default: break;
66
+ }
67
+ return run;
68
+ }
69
+
70
+ /** Fold a whole event list into a fresh record. */
71
+ export function runFromEvents(id, events, opts = {}) {
72
+ const run = emptyRun({ id, ...opts });
73
+ for (const ev of events || []) foldRun(run, ev);
74
+ return run;
75
+ }
76
+
77
+ /**
78
+ * What `resumeTeam` needs, from the record alone — the runner's own checkpoint when the run
79
+ * ended with one, else one built from the folded tasks: whatever the process that ran it
80
+ * managed to write before it went. A task recorded `running` (its process died) resumes
81
+ * from its transcript like a waiting one.
82
+ */
83
+ export function checkpointFrom(run) {
84
+ if (!run?.plan?.tasks?.length) return null;
85
+ if (run.checkpoint?.plan?.tasks?.length) return { ...run.checkpoint, board: run.threads && run.threads.threads?.length >= (run.checkpoint.board?.threads?.length || 0) ? run.threads : run.checkpoint.board };
86
+ return {
87
+ runId: run.id,
88
+ startedAt: run.startedAt || run.createdAt,
89
+ plan: run.plan,
90
+ tasks: run.tasks.map((t) => ({ id: t.id, role: t.role, title: t.title, status: t.status === 'running' || t.status === 'pending' ? 'stopped' : t.status, text: t.text || '', error: t.error || null, transcript: t.transcript || [], attempts: t.attempts || [], usage: null })),
91
+ board: run.threads || emptyBoardState(),
92
+ budget: { cap: run.usage?.cap || run.budget || {}, spent: run.usage?.spent || {} },
93
+ budgetAsked: false,
94
+ };
95
+ }
96
+
97
+ /** Can this record be picked up again? Not one that completed, not one still being run by a live client. */
98
+ export function isResumable(run) {
99
+ if (!run?.plan?.tasks?.length) return false;
100
+ if (RESUMABLE_RUN_STATUSES.includes(run.status)) return true;
101
+ return !!run.stale && LIVE_RUN_STATUSES.includes(run.status); // its client went away mid-run
102
+ }
package/team-run.js CHANGED
@@ -22,6 +22,7 @@ import { fixedPlan, plannerPrompt, parsePlan, waves } from './team-plan.js';
22
22
  import { createBoard, parseFindings, boardText, findingsInstruction, toBriefClaims, RUNNER } from './team-board.js';
23
23
  import { boardToolProvider, createAnswerBox, withBoardTool, DEFAULT_ASK_TIMEOUT_MS } from './board-tool.js';
24
24
  import { createRunCache, withRunCache } from './team-cache.js';
25
+ import { messagesFor, mergeTranscript, clipTranscript, clipMessage as clipTranscriptOne, newSteps, continuationNote } from './team-task.js';
25
26
  import { converge } from './promotion.js';
26
27
 
27
28
  export const RUN_STATUSES = Object.freeze(['planning', 'running', 'merging', 'waiting', 'completed', 'partial', 'over-budget', 'stopped', 'failed']);
@@ -96,6 +97,9 @@ export async function runTeam({
96
97
  answers = null, askTimeoutMs = DEFAULT_ASK_TIMEOUT_MS,
97
98
  // A checkpoint from a run that ended `waiting` — see resumeTeam.
98
99
  resume = null,
100
+ // The host's control channel (team-task.js createControl): a person hands a task to another
101
+ // model from the board, on either client; the runner continues the task's transcript there.
102
+ control = null,
99
103
  } = {}) {
100
104
  if (typeof callModel !== 'function') throw new TeamRunError('BAD_RUN', 'callModel required');
101
105
  const t = normalizeTeam(team); // throws on a team without a budget — O1
@@ -111,9 +115,12 @@ export async function runTeam({
111
115
  const box = answers || createAnswerBox();
112
116
  const askMs = answers ? askTimeoutMs : 0;
113
117
  const startedAt = resume?.startedAt || now();
114
- // Tasks a checkpoint already finished are carried over, not re-run.
118
+ // Tasks a checkpoint already finished are carried over, not re-run. Tasks it left
119
+ // interrupted — waiting, stopped, failed, or running when the process died — are resumed
120
+ // from their transcripts, not started again.
115
121
  const tasksOut = (resume?.tasks || []).filter((x) => x.status === 'ok').map((x) => ({ ...x }));
116
122
  const carried = new Set(tasksOut.map((x) => x.id));
123
+ const interrupted = new Map((resume?.tasks || []).filter((x) => x.status !== 'ok' && Array.isArray(x.transcript) && x.transcript.length).map((x) => [x.id, x]));
117
124
  const roleOf = (rid) => t.roles.find((r) => r.id === rid);
118
125
  // `exclude` holds what failed as unavailable this run; a re-appointment skips it. A role's
119
126
  // pinned model is tried first and, when it is the one that failed, the roster steps in.
@@ -153,7 +160,9 @@ export async function runTeam({
153
160
  const usage = budget.snapshot();
154
161
  const out = { runId: id, team: t.name, status, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.all(), threads: board.state(), usage, lookups: { distinct: runCache.size, shared: runCache.shared }, startedAt, endedAt: now(), ...extra };
155
162
  // What a resume needs, on the record: the plan, what finished, the board, the spend.
156
- if (status === 'waiting') out.checkpoint = { runId: id, startedAt, plan: { by: planBy, tasks }, tasks: tasksOut, board: board.state(), budget: { cap: budget.cap, spent: usage.spent } };
163
+ // A run that did not complete can be resumed from its record the plan, every task's
164
+ // transcript and status, the board, the spend — by any client, any time later.
165
+ if (status !== 'completed') out.checkpoint = { runId: id, startedAt, plan: { by: planBy, tasks }, tasks: tasksOut.map((x) => ({ ...x, findings: undefined })), board: board.state(), budget: { cap: budget.cap, spent: usage.spent }, budgetAsked: !!extra.budgetAsked };
157
166
  say('run.done', { status, usage, proposal: out.proposal || null, failedTaskIds: tasksOut.filter((x) => x.status === 'failed').map((x) => x.id), waitingTaskIds: tasksOut.filter((x) => x.status === 'waiting').map((x) => x.id), ...(out.checkpoint ? { checkpoint: out.checkpoint } : {}) });
158
167
  return out;
159
168
  };
@@ -179,16 +188,33 @@ export async function runTeam({
179
188
  if (stopped() || overBudget || waitingOnPerson) { tasksOut.push({ id: task.id, role: task.role, status: 'skipped', text: '', findings: [] }); return; }
180
189
  const role = roleOf(task.role);
181
190
  const t0 = now();
182
- say('task.started', { taskId: task.id, role: task.role, title: task.title });
191
+ const was = interrupted.get(task.id) || null;
192
+ say('task.started', { taskId: task.id, role: task.role, title: task.title, ...(was ? { resumed: true, steps: was.transcript.length } : {}) });
183
193
  let text = '';
184
194
  let usage = null;
185
195
  let status = 'ok';
186
196
  let error = null;
197
+ // THE TASK'S TRANSCRIPT — its conversation so far, apart from any one model. An attempt
198
+ // continues it; a hand-off continues it on another model; a resume continues it from
199
+ // the record. Only a task that has never been attempted starts from the bare prompt.
200
+ let transcript = was ? [...was.transcript] : [];
201
+ const attempts = was?.attempts ? [...was.attempts] : [];
187
202
  // The task's own abort: an ask nobody answered in time stops THIS member's turn (the
188
- // run then checkpoints), without stopping the run's other members.
189
- const taskAc = new AbortController();
190
- signal?.addEventListener?.('abort', () => taskAc.abort(), { once: true });
203
+ // run then checkpoints), without stopping the run's other members. A person's hand-off
204
+ // aborts it too, and names where the task continues.
205
+ let taskAc = new AbortController();
206
+ const onRunAbort = () => taskAc.abort();
207
+ signal?.addEventListener?.('abort', onRunAbort, { once: true });
191
208
  let askedAndWaiting = null;
209
+ let handoffTo = control?._pendingFor?.(task.id) || null;
210
+ const unsubscribe = control?._subscribe?.((req) => {
211
+ if (req.type !== 'handoff' || req.taskId !== task.id) return false;
212
+ handoffTo = req; taskAc.abort(); return true;
213
+ }) || null;
214
+ const recordSteps = (before, after) => {
215
+ const added = newSteps(before, after);
216
+ if (added.length) say('task.step', { taskId: task.id, role: role.id, steps: added.map((m) => clipTranscriptOne(m)) });
217
+ };
192
218
  try {
193
219
  if (role.mode === 'recipe') {
194
220
  if (typeof runRecipe !== 'function') throw new Error('this host cannot run recipes');
@@ -219,20 +245,53 @@ export async function runTeam({
219
245
  // models. Anything else (a refusal, a timeout, a bad request) fails the task.
220
246
  const exclude = new Set();
221
247
  let lastErr = '';
248
+ // What the next attempt is told, when it continues rather than starts.
249
+ let note = was ? continuationNote(was.status === 'waiting' ? { kind: 'answer', answer: 'see the board' } : { kind: 'resume', reason: was.error || was.status }) : null;
250
+ let lastModel = was?.attempts?.at?.(-1)?.model || null;
222
251
  for (let attempt = 1; ; attempt++) {
223
- const m = modelFor(role, excluding(exclude));
252
+ let m;
253
+ if (handoffTo) {
254
+ // A person's hand-off names the model; the task continues there whatever the
255
+ // roster would have chosen. Said on the board, so everyone knows who has it.
256
+ m = { model: handoffTo.model, mode: role.mode };
257
+ const th = board.threadForTask(task.id);
258
+ if (th) board.post({ threadId: th.id, by: RUNNER, kind: 'decision', text: `Handed off from ${lastModel || 'the roster'} to ${handoffTo.model} by ${handoffTo.by}${handoffTo.reason ? ` — ${handoffTo.reason}` : ''}.` });
259
+ say('task.handoff', { taskId: task.id, role: role.id, from: lastModel, to: handoffTo.model, by: handoffTo.by, reason: handoffTo.reason || '' });
260
+ note = continuationNote({ kind: 'handoff', from: lastModel, to: handoffTo.model, reason: `handed off by ${handoffTo.by}` });
261
+ handoffTo = null;
262
+ taskAc = new AbortController(); signal?.addEventListener?.('abort', onRunAbort, { once: true });
263
+ } else {
264
+ m = modelFor(role, excluding(exclude));
265
+ }
224
266
  if (!m?.model) throw new Error(exclude.size ? `no model left for role "${role.id}" after ${[...exclude].join(', ')}` : `no model for role "${role.id}"`);
225
- if (attempt > 1) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude], error: lastErr });
267
+ if (attempt > 1 && lastErr) say('task.reappointed', { taskId: task.id, role: role.id, model: m.model, after: [...exclude], error: lastErr });
226
268
  // Who is doing this task, for a ledger that shows the lanes — said per attempt.
227
269
  say('task.model', { taskId: task.id, role: role.id, model: m.model, attempt });
270
+ attempts.push({ model: m.model, at: now(), continued: !!note });
271
+ const sent = messagesFor({ transcript }, { prompt, note });
272
+ // The record grows AS THE ATTEMPT GOES: a host that reports each wire message the
273
+ // moment it exists (a tool call, its result) puts it on the record then, so a
274
+ // process that dies mid-attempt leaves the work so far behind it, not nothing.
275
+ let live = sent;
276
+ if (sent.length > transcript.length) recordSteps(transcript, sent);
277
+ const onStep = (message) => { if (message && message.role) { live = [...live, message]; say('task.step', { taskId: task.id, role: role.id, steps: [clipTranscriptOne(message)] }); } };
228
278
  const res = await callModel({
229
279
  runId: id, taskId: task.id, role: role.id, model: m.model, mode: m.mode || role.mode,
230
- system: role.prompt, prompt, tools, signal: taskAc.signal,
280
+ system: role.prompt, prompt, messages: sent, tools, signal: taskAc.signal,
231
281
  onDelta: (delta, full) => say('task.delta', { taskId: task.id, role: role.id, delta, text: full }),
282
+ onStep,
232
283
  });
233
284
  usage = res?.usage || null;
234
285
  if (usage) budget.charge(usage);
286
+ // Whatever the attempt did is the task's now — on the record, before any verdict.
287
+ // What the host already reported step by step is not reported again.
288
+ transcript = mergeTranscript(sent, res);
289
+ if (transcript.length < live.length) transcript = live;
290
+ recordSteps(live, transcript);
291
+ lastModel = m.model;
292
+ attempts[attempts.length - 1].status = res?.ok ? (String(res?.text || '').trim() ? 'ok' : 'empty') : 'error';
235
293
  if (askedAndWaiting) { status = 'waiting'; waitingOnPerson = true; break; }
294
+ if (handoffTo) { note = null; continue; } // the person moved it: continue the transcript there
236
295
  if (res?.aborted || taskAc.signal.aborted) { status = 'stopped'; break; }
237
296
  // A turn that ended with nothing to say — an agent that exited, a stream that
238
297
  // died after its tool calls — is not a done task: three members "completed" empty
@@ -249,19 +308,26 @@ export async function runTeam({
249
308
  if (mine.length) { text = mine.map((p) => p.text).join('\n\n'); say('task.note', { taskId: task.id, role: role.id, text: 'answered from its board posts' }); break; }
250
309
  }
251
310
  const err = res?.ok ? 'the model returned no answer' : (res?.error || 'the model did not answer');
311
+ attempts[attempts.length - 1].error = err;
252
312
  if (attempt >= MAX_APPOINTMENTS || stopped() || !isModelUnavailable(err)) throw new Error(err);
253
313
  exclude.add(m.model); runExclude.add(m.model); lastErr = err;
314
+ // The next model CONTINUES this transcript; it does not start over.
315
+ note = continuationNote({ kind: 'handoff', from: m.model, reason: err });
316
+ const th = board.threadForTask(task.id);
317
+ if (th) board.post({ threadId: th.id, by: RUNNER, kind: 'note', text: `${m.model} stopped (${err.slice(0, 160)}); the task continues on the next model with its work so far.` });
254
318
  }
255
319
  }
256
320
  } catch (e) {
257
321
  if (askedAndWaiting) { status = 'waiting'; waitingOnPerson = true; }
258
322
  else { status = overBudget ? 'over-budget' : 'failed'; error = String(e?.message || e); }
323
+ } finally {
324
+ unsubscribe?.();
259
325
  }
260
326
  const findings = status === 'ok' ? parseFindings(text, { role: role.id, taskId: task.id }) : [];
261
327
  if (findings.length) { board.add(findings); for (const f of findings) say('task.finding', { taskId: task.id, role: role.id, finding: f }); }
262
328
  const thread = board.threadForTask(task.id);
263
329
  if (thread && status !== 'waiting') board.setThreadStatus(thread.id, 'resolved');
264
- const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
330
+ const row = { id: task.id, role: task.role, title: task.title, status, text, error, usage, ms: now() - t0, findings, transcript: clipTranscript(transcript), attempts, ...(askedAndWaiting ? { waitingOn: askedAndWaiting } : {}) };
265
331
  tasksOut.push(row);
266
332
  say(status === 'ok' ? 'task.done' : 'task.failed', { taskId: task.id, role: task.role, status, error, ms: row.ms, findings: findings.length, ...(askedAndWaiting ? { threadId: askedAndWaiting } : {}) });
267
333
  // The spend so far, after every task — a ledger reads it live instead of at the end.
@@ -344,11 +410,14 @@ export async function runTeam({
344
410
  return finish(failed ? 'partial' : 'completed', { proposal });
345
411
  }
346
412
 
347
- /** Continue a run that ended `waiting` from its checkpoint — the answered ask is on the board. */
413
+ /**
414
+ * Continue a run from its checkpoint — one that waited on a person, was stopped, failed, or
415
+ * whose process died. Finished tasks are carried; interrupted ones continue from their
416
+ * transcripts on whatever model the roster gives them now (or a hand-off names); the board
417
+ * and the spend carry over. Any client, any time later: the checkpoint is on the record.
418
+ */
348
419
  export function resumeTeam({ checkpoint, ...deps } = {}) {
349
420
  if (!checkpoint?.plan?.tasks) throw new TeamRunError('BAD_RESUME', 'a checkpoint with a plan is required');
350
- // The waiting task runs again; its ask thread now holds the answer, and boardText gives it
351
- // to the member. Tasks recorded `waiting`/`skipped` are dropped from the carried list.
352
421
  return runTeam({ ...deps, resume: checkpoint });
353
422
  }
354
423
 
package/team-task.js ADDED
@@ -0,0 +1,111 @@
1
+ // A task is a conversation, and a model call is one attempt at continuing it.
2
+ //
3
+ // A member's task keeps its TRANSCRIPT — the wire messages so far: the request, what the
4
+ // model said, the tools it called and what came back — apart from whichever model or CLI
5
+ // agent happens to be answering. When that model fails, is handed off, or the process
6
+ // itself dies and the run is resumed a month later from the store, the next model does not
7
+ // start over: it is given the transcript and told to continue. The same thing a chat window
8
+ // does when a person switches models mid-conversation; here it is what makes a run survive
9
+ // a process, a model, or an API going away.
10
+ //
11
+ // Every attempt, hand-off and continuation is said on the board (a runner post in the
12
+ // task's thread) and in the run's events, so the record — on the gateway, read by either
13
+ // client — is enough to pick the task up from anywhere.
14
+
15
+ export const STEP_MAX_CHARS = 4000; // one persisted message's content
16
+ export const TASK_TRANSCRIPT_MAX_CHARS = 60_000; // a task's whole persisted transcript
17
+
18
+ const clipStr = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}\n…[${s.length - n} more chars omitted from the record]` : s);
19
+
20
+ /** A wire message trimmed for the record: tool results and long answers are clipped. */
21
+ export function clipMessage(m) {
22
+ if (!m || typeof m !== 'object') return m;
23
+ const out = { role: m.role };
24
+ if (typeof m.content === 'string') out.content = clipStr(m.content, STEP_MAX_CHARS);
25
+ else if (m.content != null) out.content = m.content;
26
+ if (m.tool_calls) out.tool_calls = m.tool_calls.map((c) => ({ id: c.id, type: c.type || 'function', function: { name: c.function?.name, arguments: clipStr(String(c.function?.arguments ?? ''), STEP_MAX_CHARS) } }));
27
+ if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
28
+ if (m.name) out.name = m.name;
29
+ return out;
30
+ }
31
+
32
+ /** The record's copy of a transcript: clipped per message and bounded as a whole (oldest tool traffic goes first). */
33
+ export function clipTranscript(messages) {
34
+ const list = (Array.isArray(messages) ? messages : []).filter((m) => m && m.role && m.role !== 'system').map(clipMessage);
35
+ let size = list.reduce((n, m) => n + JSON.stringify(m).length, 0);
36
+ // Drop the oldest tool exchanges (assistant tool_calls + tool results) until it fits,
37
+ // never the first user message (the task) and never the last message.
38
+ for (let i = 1; size > TASK_TRANSCRIPT_MAX_CHARS && i < list.length - 1; ) {
39
+ const m = list[i];
40
+ if (m.role === 'tool' || (m.role === 'assistant' && m.tool_calls)) { size -= JSON.stringify(m).length; list.splice(i, 1); } else i += 1;
41
+ }
42
+ return list;
43
+ }
44
+
45
+ /** The messages beyond what the record already holds — what one attempt added. */
46
+ export function newSteps(prev, next) {
47
+ const a = Array.isArray(prev) ? prev.length : 0;
48
+ return (Array.isArray(next) ? next : []).slice(a);
49
+ }
50
+
51
+ /**
52
+ * What the next model is told when it continues another's work. `kind`:
53
+ * handoff — the previous attempt (another model / agent) stopped: an error, a person's choice
54
+ * resume — the run itself was stopped or died and is being resumed from the record
55
+ * answer — the task waited on a person and the answer is now on the board
56
+ */
57
+ export function continuationNote({ kind = 'handoff', from = '', to = '', reason = '', answer = '' } = {}) {
58
+ const who = from ? ` by ${from}` : '';
59
+ if (kind === 'answer') return `The user has answered your question on the board (see the board, or: ${answer}). Continue the task from where you left off — do not repeat work already done above — and finish with your findings.`;
60
+ if (kind === 'resume') return `This task was interrupted (${reason || 'the run was stopped'}) and is being resumed${to ? ` by ${to}` : ''}. Everything above is the work done so far — read it, do not redo it. Continue from where it stopped and finish with your findings.`;
61
+ return `You are continuing this task. A previous attempt${who} stopped (${reason || 'it did not finish'}). Everything above is its work so far — read it, do not redo lookups already made. Continue from where it stopped and finish with your findings.`;
62
+ }
63
+
64
+ /**
65
+ * The messages to send for an attempt: the task's transcript plus a continuation note when
66
+ * there is one; the bare task when there is not. `system` travels separately (the host puts
67
+ * it first) so a transcript never carries a role prompt that a later role might not share.
68
+ */
69
+ export function messagesFor(task, { prompt, note = null } = {}) {
70
+ const transcript = Array.isArray(task?.transcript) ? task.transcript : [];
71
+ if (!transcript.length) return [{ role: 'user', content: String(prompt || '') }];
72
+ const last = transcript[transcript.length - 1];
73
+ // A transcript that ends in an unanswered tool call cannot be continued as-is: close it.
74
+ const closed = last?.role === 'assistant' && Array.isArray(last.tool_calls) && last.tool_calls.length
75
+ ? [...transcript, ...last.tool_calls.map((c) => ({ role: 'tool', tool_call_id: c.id, content: 'This call was not answered: the attempt stopped here.' }))]
76
+ : transcript;
77
+ return note ? [...closed, { role: 'user', content: note }] : closed;
78
+ }
79
+
80
+ /**
81
+ * Fold what a host returned into the task's transcript. A host that hands back its wire
82
+ * transcript (`res.transcript`) is taken as is (minus system); one that only returns text
83
+ * gets the messages it was sent plus one assistant message — still a continuation.
84
+ */
85
+ export function mergeTranscript(sent, res) {
86
+ const returned = Array.isArray(res?.transcript) ? res.transcript.filter((m) => m && m.role !== 'system') : null;
87
+ if (returned && returned.length >= sent.length) return returned;
88
+ const text = String(res?.text || '');
89
+ return text ? [...sent, { role: 'assistant', content: text }] : sent;
90
+ }
91
+
92
+ /**
93
+ * The control channel a host holds on a running team: a person's hand-off of a task to a
94
+ * named model (from the board, either client), or a stop of one task. The runner subscribes.
95
+ */
96
+ export function createControl() {
97
+ const listeners = new Set();
98
+ const pending = new Map(); // taskId -> { model, by } asked before the task subscribed
99
+ const api = {
100
+ handoff(taskId, model, by = 'person', reason = '') {
101
+ const req = { type: 'handoff', taskId, model, by, reason };
102
+ let taken = false;
103
+ for (const fn of listeners) { if (fn(req) === true) taken = true; }
104
+ if (!taken) pending.set(taskId, req);
105
+ return taken;
106
+ },
107
+ _subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
108
+ _pendingFor(taskId) { const r = pending.get(taskId); if (r) pending.delete(taskId); return r || null; },
109
+ };
110
+ return api;
111
+ }
package/team-trail.js CHANGED
@@ -9,12 +9,15 @@ export function teamLine(ev) {
9
9
  switch (ev.type) {
10
10
  case 'run.started': return { type: 'status', text: `team ${ev.team}: ${(ev.roles || []).join(', ')}` };
11
11
  case 'plan.ready': return { type: 'status', text: `plan: ${(ev.tasks || []).length} task${(ev.tasks || []).length === 1 ? '' : 's'} (${ev.by})` };
12
- case 'task.started': return { type: 'tool', name: role, text: `${role} · ${ev.title || ev.taskId}` };
12
+ case 'task.started': return { type: 'tool', name: role, text: `${role} · ${ev.title || ev.taskId}${ev.resumed ? ` (resumed, ${ev.steps} steps so far)` : ''}` };
13
13
  case 'task.waiting': return { type: 'status', text: `${role} is waiting on you — ${ev.text || 'a question on the board'}` };
14
14
  case 'run.waiting': return { type: 'status', text: `waiting on you — ${ev.text || ev.type || 'a question on the board'}` };
15
15
  case 'run.resumed': return { type: 'status', text: `team ${ev.team} resumed${(ev.carried || []).length ? ` (${ev.carried.length} task${ev.carried.length === 1 ? '' : 's'} carried over)` : ''}` };
16
16
  case 'board.post': return ev.post && ev.post.kind !== 'finding' ? { type: 'status', text: `${ev.post.by} ${ev.post.replyTo ? 'replied' : 'posted'} (${ev.post.kind}): ${String(ev.post.text || '').slice(0, 120)}` } : null;
17
17
  case 'board.decision': return { type: 'status', text: `${ev.by || 'someone'} ${ev.status} a post` };
18
+ case 'task.note': return { type: 'status', text: `${role}: ${ev.text}` };
19
+ case 'task.handoff': return { type: 'status', text: `${role} handed off ${ev.from ? `from ${ev.from} ` : ''}to ${ev.to} by ${ev.by || 'person'}${ev.reason ? ` — ${ev.reason}` : ''}` };
20
+ case 'task.step': return null;
18
21
  case 'task.reappointed': return { type: 'status', text: `${role} → ${ev.model} (${(ev.after || []).join(', ')} unavailable${ev.error ? `: ${String(ev.error).slice(0, 120)}` : ''})` };
19
22
  case 'task.tool': return { type: 'tool', name: ev.name, text: `${role} ran ${ev.name}${ev.text ? ` — ${ev.text}` : ''}` };
20
23
  case 'task.finding': return { type: 'status', text: `${role}: ${String(ev.finding?.text || '').slice(0, 140)}` };
@@ -35,8 +38,9 @@ export function teamLanes(prev, ev) {
35
38
  case 'task.started': lanes.tasks[ev.taskId] = { ...(lanes.tasks[ev.taskId] || { id: ev.taskId, role: ev.role, title: ev.title }), status: 'running' }; break;
36
39
  case 'task.delta': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], text: ev.text }; break;
37
40
  case 'task.finding': lanes.findings += 1; if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], findings: (lanes.tasks[ev.taskId].findings || 0) + 1 }; break;
38
- case 'task.note': return { type: 'status', text: `${role}: ${ev.text}` };
39
41
  case 'task.model': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.model }; break;
42
+ case 'task.handoff': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], model: ev.to, handoffs: (lanes.tasks[ev.taskId].handoffs || 0) + 1 }; break;
43
+ case 'task.step': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], steps: (lanes.tasks[ev.taskId].steps || 0) + (ev.steps || []).length }; break;
40
44
  case 'task.tool': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], tools: (lanes.tasks[ev.taskId].tools || 0) + 1, lastTool: ev.text ? `${ev.name} ${ev.text}` : ev.name }; break;
41
45
  case 'run.usage': lanes.usage = ev.usage; break;
42
46
  case 'task.waiting': if (lanes.tasks[ev.taskId]) lanes.tasks[ev.taskId] = { ...lanes.tasks[ev.taskId], status: 'waiting', waitingOn: ev.threadId }; lanes.waiting = [...(lanes.waiting || []), ev.threadId]; break;