@yeaft/webchat-agent 0.1.480 → 0.1.482

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/crew-i18n.js CHANGED
@@ -133,7 +133,11 @@ ${isDevTeam ? '3' : '2'}. **任务完成** - 所有任务已完成,给出完
133
133
  // dev team
134
134
  developer: (dm) => `代码完成后,必须同时发两个 ROUTE 块分别交给审查者和测试者(缺一不可)。
135
135
  UI/交互方案不确定时找设计师确认。需求不明确时找决策者 "${dm}" 确认。
136
- **⚠️ 你的回复必须以 ROUTE 块结尾。不发 ROUTE = 代码无法进入审查流程。**`,
136
+ **⚠️ 你的回复必须以 ROUTE 块结尾。不发 ROUTE = 代码无法进入审查流程。**
137
+
138
+ > 🚨 **合并与发布 — Dev 严禁自打 tag**
139
+ > 不得执行 \`git tag\` 或 push \`v*\` / \`release-*\` 到 origin,除非用户明确要求 release。
140
+ > tag 由 PM/reviewer 在 main 分支上执行。详见 \`.crew/CLAUDE.md\` "代码合并与发布规则" 和 \`CONTRIBUTING.md\` "🚨 Tagging & Release Rules"。`,
137
141
  reviewer_dev: (dm) => `审查完成后:评分 ≥ 9分通过,ROUTE 给对应的测试者(如 rev-1 → test-1)告知审查通过、可以开始测试。评分 < 9分打回给开发者修改。
138
142
  遇到架构层面的问题找决策者 "${dm}" 讨论。
139
143
  **⚠️ 你的回复必须以 ROUTE 块结尾。不发 ROUTE = 审查结果无法传递。**`,
@@ -188,7 +192,15 @@ UI/交互方案不确定时找设计师确认。需求不明确时找决策者 "
188
192
  - **Dev 严禁自己打 tag** — tag 只能由 PM 或 reviewer 合并后打
189
193
  - **Dev 完成后必须 ROUTE 给 reviewer** — 不 ROUTE 等于没完成,消息无法传递
190
194
  - **即使是小修复(一行改动),也必须走 PR + review 流程**
191
- - **合并和打 tag 由 reviewer 或 PM 执行**,dev 不自己合并`,
195
+ - **合并和打 tag 由 reviewer 或 PM 执行**,dev 不自己合并
196
+
197
+ ## Tagging Checklist(任何角色 tag 前必读)
198
+ 1. **分支检查**:当前分支必须是 main(\`git branch --show-current\` == \`main\`),非 main 分支一律不得 tag
199
+ 2. **Conventional commits**:commit message 使用 \`feat:\` / \`fix:\` / \`perf:\` 等规范前缀
200
+ 3. **不得自打 tag**:除非用户明确要求 release,否则 dev/reviewer/tester 角色不得执行 \`git tag\`
201
+ 4. **Tag 前确认 origin/main**:被 tag 的 commit 必须已经在 \`origin/main\` 上(\`git merge-base --is-ancestor <sha> origin/main\`)
202
+
203
+ 完整规则与 2026-04-17 违规案例见 \`CONTRIBUTING.md\` "🚨 Tagging & Release Rules"。`,
192
204
  taskSplitRules: '# 任务拆分原则',
193
205
  taskSplitRulesContent: `- **不相干的修改必须分给不同的 dev,产出独立的 PR**。严禁把两个不相关的 bug fix / feature 放在同一个 PR 中。
194
206
  - 即使两个改动看似相关(如同一个 bug 的两个方面),如果它们改的文件不同、逻辑独立,也应该拆成独立 task + 独立 PR。
@@ -393,7 +405,11 @@ Execute stop-losses immediately when triggered and notify the decision maker. Re
393
405
  // dev team
394
406
  developer: (dm) => `After code is complete, you must send two ROUTE blocks simultaneously to reviewer and tester (both required).
395
407
  Check with designer for UI/interaction questions. Check with decision maker "${dm}" for unclear requirements.
396
- **⚠️ Your reply MUST end with ROUTE blocks. Not sending a ROUTE = code cannot enter the review process.**`,
408
+ **⚠️ Your reply MUST end with ROUTE blocks. Not sending a ROUTE = code cannot enter the review process.**
409
+
410
+ > 🚨 **Merge & Release — Dev MUST NOT self-tag**
411
+ > Do NOT run \`git tag\` or push \`v*\` / \`release-*\` to origin unless the user explicitly requested a release.
412
+ > Tags are created by PM/reviewer on the \`main\` branch only. See \`.crew/CLAUDE.md\` "Code Merge & Release Rules" and \`CONTRIBUTING.md\` "🚨 Tagging & Release Rules" for details.`,
397
413
  reviewer_dev: (dm) => `After review: score >= 9 passes, ROUTE to the corresponding tester (e.g., rev-1 → test-1) to notify review passed and testing can begin. Score < 9: send back to developer with issues.
398
414
  Discuss architecture-level concerns with decision maker "${dm}".
399
415
  **⚠️ Your reply MUST end with a ROUTE block. Not sending a ROUTE = review results cannot be passed on.**`,
@@ -447,7 +463,15 @@ After development is complete, send two ROUTE blocks simultaneously to ${revName
447
463
  - **Dev must never create tags** — Tags can only be created by PM or reviewer after merge
448
464
  - **Dev must ROUTE to reviewer after completion** — Not ROUTEing = work not delivered, message cannot be passed
449
465
  - **Even small fixes (single-line changes) must go through PR + review workflow**
450
- - **Merge and tagging are done by reviewer or PM** — dev does not merge themselves`,
466
+ - **Merge and tagging are done by reviewer or PM** — dev does not merge themselves
467
+
468
+ ## Tagging Checklist (required reading before any \`git tag\`)
469
+ 1. **Branch check**: current branch MUST be \`main\` (\`git branch --show-current\` == \`main\`); never tag from a feature/worktree branch
470
+ 2. **Conventional commits**: commit messages use \`feat:\` / \`fix:\` / \`perf:\` prefixes
471
+ 3. **No self-tagging**: unless the user explicitly requested a release, dev/reviewer/tester roles MUST NOT run \`git tag\`
472
+ 4. **Verify origin/main before tag**: the commit being tagged must already be on \`origin/main\` (\`git merge-base --is-ancestor <sha> origin/main\`)
473
+
474
+ Full rules and the 2026-04-17 violation precedent are in \`CONTRIBUTING.md\` "🚨 Tagging & Release Rules".`,
451
475
  taskSplitRules: '# Task Split Principles',
452
476
  taskSplitRulesContent: `- **Unrelated changes must go to different devs with independent PRs**. Never put two unrelated bug fixes / features in the same PR.
453
477
  - Even if two changes seem related (e.g., two aspects of the same bug), if they modify different files and have independent logic, they should be split into independent tasks + independent PRs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.480",
3
+ "version": "0.1.482",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -275,8 +275,10 @@ export class Engine {
275
275
  // task-299 Phase 1: tag persisted messages with the current thread.
276
276
  // getThreadStore() lazily seeds a default 'main' thread if not yet init'd.
277
277
  let threadId = MAIN_THREAD_ID;
278
+ let threadStore = null;
278
279
  try {
279
- threadId = getThreadStore().currentId || MAIN_THREAD_ID;
280
+ threadStore = getThreadStore();
281
+ threadId = threadStore.currentId || MAIN_THREAD_ID;
280
282
  } catch {
281
283
  // Defensive: any store failure falls back to 'main' so persistence
282
284
  // never breaks because of thread bookkeeping.
@@ -302,6 +304,18 @@ export class Engine {
302
304
  assistantMsg.toolCalls = toolCalls;
303
305
  }
304
306
  this.#conversationStore.append(assistantMsg);
307
+
308
+ // task-299 Phase 1 cached-field update: bump thread counters twice
309
+ // (once for user, once for assistant). Any exception is swallowed so
310
+ // bookkeeping never blocks the main persist path.
311
+ try {
312
+ if (threadStore) {
313
+ threadStore.noteMessage(threadId);
314
+ threadStore.noteMessage(threadId);
315
+ }
316
+ } catch {
317
+ // Non-critical; counters can be rebuilt via rebuildFromMessages().
318
+ }
305
319
  }
306
320
 
307
321
  /**
@@ -338,7 +352,13 @@ export class Engine {
338
352
  * Yields EngineEvent objects that the caller (CLI, web) can consume
339
353
  * to render output in real-time.
340
354
  *
341
- * @param {{ prompt: string, mode?: string, messages?: Array, signal?: AbortSignal }} params
355
+ * @param {object} params
356
+ * @param {string} params.prompt - The user prompt (required, non-empty).
357
+ * @param {'dream'} [params.mode] - Optional mode flag. Since task-297 the only
358
+ * value accepted / acted on is `'dream'` (memory maintenance system prompt).
359
+ * Any other value is ignored and falls through to the unified system prompt.
360
+ * @param {Array} [params.messages] - Prior conversation messages.
361
+ * @param {AbortSignal} [params.signal] - Abort signal.
342
362
  * @yields {EngineEvent}
343
363
  */
344
364
  async *query({ prompt, mode, messages = [], signal }) {
package/unify/session.js CHANGED
@@ -25,6 +25,8 @@ import { createFullRegistry } from './tools/index.js';
25
25
  import { initTaskStore } from './tools/task-tools.js';
26
26
  import { initThreadStore } from './threads/store.js';
27
27
  import { Engine } from './engine.js';
28
+ import { createThreadEngineRegistry } from './threads/engine-registry.js';
29
+ import { MAIN_THREAD_ID } from './threads/store.js';
28
30
  import { join } from 'path';
29
31
 
30
32
  /**
@@ -166,6 +168,25 @@ export async function loadSession(options = {}) {
166
168
  yeaftDir,
167
169
  });
168
170
 
171
+ // task-308 Phase 2: thread-aware engine registry.
172
+ // Each thread gets its own EngineInstance (lazy-created) that owns its
173
+ // messages array and tags all events with the bound threadId. Legacy
174
+ // single-engine callers keep working via `session.engine`; multi-thread
175
+ // callers use `session.engineRegistry.ensure(threadId)`.
176
+ const engineRegistry = createThreadEngineRegistry({
177
+ adapter,
178
+ trace,
179
+ config,
180
+ conversationStore,
181
+ memoryStore,
182
+ toolRegistry,
183
+ skillManager,
184
+ mcpManager,
185
+ yeaftDir,
186
+ });
187
+ // Seed the main-thread instance so listActive() is non-empty from T=0.
188
+ engineRegistry.ensure(MAIN_THREAD_ID);
189
+
169
190
  // ─── 10. Build session ─────────────────────────────────
170
191
  const status = {
171
192
  skills: skillManager.size,
@@ -176,6 +197,11 @@ export async function loadSession(options = {}) {
176
197
 
177
198
  /** Graceful shutdown: disconnect MCP, close trace DB. */
178
199
  async function shutdown() {
200
+ try {
201
+ engineRegistry.terminateAll();
202
+ } catch {
203
+ // Best-effort cleanup
204
+ }
179
205
  try {
180
206
  await mcpManager.disconnectAll();
181
207
  } catch {
@@ -190,6 +216,7 @@ export async function loadSession(options = {}) {
190
216
 
191
217
  return {
192
218
  engine,
219
+ engineRegistry,
193
220
  adapter,
194
221
  config,
195
222
  conversationStore,
@@ -31,7 +31,12 @@ function serializeTask(task) {
31
31
  `priority: ${task.priority || 'medium'}`,
32
32
  ];
33
33
 
34
+ // task-299 (Q3 rework): parentTaskId is the canonical field per design §5.
35
+ // parentId is kept as a legacy mirror for backward compat with any tool
36
+ // that still reads it; both are always in sync after migration.
37
+ if (task.parentTaskId) fm.push(`parentTaskId: ${task.parentTaskId}`);
34
38
  if (task.parentId) fm.push(`parentId: ${task.parentId}`);
39
+ if (task.primaryThreadId) fm.push(`primaryThreadId: ${task.primaryThreadId}`);
35
40
  if (task.createdAt) fm.push(`createdAt: ${task.createdAt}`);
36
41
  if (task.updatedAt) fm.push(`updatedAt: ${task.updatedAt}`);
37
42
 
@@ -92,9 +97,19 @@ function parseTask(raw) {
92
97
  task.description = body;
93
98
  }
94
99
 
95
- // Normalize parentId
96
- if (!task.parentId || task.parentId === 'null') {
97
- task.parentId = null;
100
+ // Normalize parentId / parentTaskId (design §5 canonical is parentTaskId).
101
+ // If only legacy parentId is present, promote it to parentTaskId so
102
+ // anything that reads the canonical field sees a value. Null/"null"
103
+ // strings become real null.
104
+ if (!task.parentId || task.parentId === 'null') task.parentId = null;
105
+ if (!task.parentTaskId || task.parentTaskId === 'null') task.parentTaskId = null;
106
+ if (!task.parentTaskId && task.parentId) task.parentTaskId = task.parentId;
107
+ if (!task.parentId && task.parentTaskId) task.parentId = task.parentTaskId;
108
+
109
+ // primaryThreadId: per design §5 clarification (task-299 Q4), null means
110
+ // "unbound / orphan" — it does NOT implicitly equal 'main'. Keep null as null.
111
+ if (!task.primaryThreadId || task.primaryThreadId === 'null') {
112
+ task.primaryThreadId = null;
98
113
  }
99
114
 
100
115
  return task;
@@ -191,6 +206,12 @@ export class TaskStore {
191
206
 
192
207
  // Load existing tasks from disk
193
208
  this.#loadAll();
209
+
210
+ // task-299 (Q3, rework): run a one-shot backfill that promotes legacy
211
+ // `parentId` to the canonical `parentTaskId` field (design §5). A
212
+ // marker file (.migrations/parentTaskId) records completion so the
213
+ // migration is skipped on subsequent boots and is idempotent.
214
+ this.#migrateParentTaskId();
194
215
  }
195
216
 
196
217
  /** Number of tasks in the store. */
@@ -275,6 +296,30 @@ export class TaskStore {
275
296
  return results;
276
297
  }
277
298
 
299
+ /**
300
+ * Return tasks grouped as a tree. Tasks with no parent (parentTaskId == null)
301
+ * are "roots"; each non-root task becomes a child of its parent.
302
+ *
303
+ * Used by task-298/task-300 to render task hierarchies. Critical for the
304
+ * Q3 migration test: before the backfill, old tasks stored only `parentId`
305
+ * and `tree()` would see every task as a root.
306
+ *
307
+ * @returns {{ roots: object[], orphans: object[] }}
308
+ * - roots : tasks whose parentTaskId is null
309
+ * - orphans : tasks whose parentTaskId points to an id that no longer exists
310
+ */
311
+ tree() {
312
+ const all = [...this.#tasks.values()];
313
+ const byId = new Map(all.map(t => [t.id, t]));
314
+ const roots = [];
315
+ const orphans = [];
316
+ for (const t of all) {
317
+ if (!t.parentTaskId) roots.push(t);
318
+ else if (!byId.has(t.parentTaskId)) orphans.push(t);
319
+ }
320
+ return { roots, orphans };
321
+ }
322
+
278
323
  /**
279
324
  * Get progress log for a task.
280
325
  * @param {string} id
@@ -391,6 +436,75 @@ export class TaskStore {
391
436
  writeFileSync(this.#indexPath, generateIndex(this.#tasks), 'utf8');
392
437
  } catch { /* */ }
393
438
  }
439
+
440
+ /**
441
+ * task-299 Q3 migration — one-shot backfill from legacy `parentId` to
442
+ * canonical `parentTaskId` (design §5).
443
+ *
444
+ * Behaviour:
445
+ * - Reads .migrations/parentTaskId meta marker. If present, returns
446
+ * immediately (skips). → guarantees idempotency on repeated boots.
447
+ * - Otherwise: for every loaded task, if parentId is set but
448
+ * parentTaskId is not, copy parentId → parentTaskId and rewrite
449
+ * task.md so the new field survives future loads.
450
+ * - Writes the meta marker on success. Read-only mode is a no-op.
451
+ *
452
+ * Exposed publicly as `migrateParentTaskId()` so tests can re-run it.
453
+ *
454
+ * @returns {{ ran: boolean, migratedCount: number }}
455
+ */
456
+ migrateParentTaskId() {
457
+ return this.#migrateParentTaskId();
458
+ }
459
+
460
+ #migrateParentTaskId() {
461
+ if (this.#readOnly) return { ran: false, migratedCount: 0 };
462
+
463
+ const markerDir = join(this.#dir, '.migrations');
464
+ const markerPath = join(markerDir, 'parentTaskId');
465
+
466
+ try {
467
+ if (existsSync(markerPath)) return { ran: false, migratedCount: 0 };
468
+ } catch {
469
+ // If existsSync throws (pathological FS), proceed cautiously — the
470
+ // migration itself is idempotent on per-task level.
471
+ }
472
+
473
+ let migratedCount = 0;
474
+ for (const task of this.#tasks.values()) {
475
+ // parseTask() already promotes parentId → parentTaskId in memory,
476
+ // but the on-disk YAML still lacks the canonical field for old
477
+ // tasks. Rewriting guarantees future loads see parentTaskId and
478
+ // makes the migration visible.
479
+ if (task.parentId && !task.__parentTaskIdWritten) {
480
+ // Ensure the canonical field is set (parseTask normalised this,
481
+ // but handle the edge case where parseTask wasn't used).
482
+ if (!task.parentTaskId) task.parentTaskId = task.parentId;
483
+ const taskDir = join(this.#dir, task.id);
484
+ try {
485
+ writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
486
+ task.__parentTaskIdWritten = true;
487
+ migratedCount += 1;
488
+ } catch {
489
+ // Best-effort; marker is only written if the pass completes.
490
+ }
491
+ }
492
+ }
493
+
494
+ try {
495
+ mkdirSync(markerDir, { recursive: true });
496
+ writeFileSync(
497
+ markerPath,
498
+ `migrated: ${new Date().toISOString()}\ncount: ${migratedCount}\n`,
499
+ 'utf8',
500
+ );
501
+ } catch {
502
+ // Without the marker the migration may re-run; since it's idempotent
503
+ // that is acceptable but not ideal. Log silently.
504
+ }
505
+
506
+ return { ran: true, migratedCount };
507
+ }
394
508
  }
395
509
 
396
510
  // Exported for testing
@@ -0,0 +1,218 @@
1
+ /**
2
+ * engine-instance.js — task-308 Phase 2.
3
+ *
4
+ * An EngineInstance binds a single `threadId` to an Engine + an independent
5
+ * per-thread `messages` array + an independent memory scope ref. Multiple
6
+ * EngineInstance objects can run .query() concurrently on the same unify
7
+ * session without cross-contaminating state:
8
+ *
9
+ * - The underlying Engine's query loop is a pure async generator that
10
+ * takes `messages` as a parameter — it holds no mutable turn state on
11
+ * `this` during a run, so concurrent generators cannot alias each
12
+ * other's conversation or tool-call arrays.
13
+ * - All yielded events are re-tagged with the instance's bound
14
+ * `threadId` (not with the global current-thread marker from the
15
+ * singleton ThreadStore), so the web-bridge can route them to the
16
+ * right pane even while several threads stream simultaneously.
17
+ * - `messages` is owned by the instance: user/assistant messages
18
+ * appended during a query are persisted to the instance's own array,
19
+ * not to a global.
20
+ *
21
+ * Memory scope: Phase 2 design doc §6 — the memory store is shared across
22
+ * threads (one user, one brain), but the EngineInstance carries a
23
+ * `memoryScope` ref that can later be used to namespace recall/query
24
+ * results by thread. Today the ref is the threadId itself; downstream
25
+ * memory adapters can opt in.
26
+ *
27
+ * Q2 decision (PM brief): all threads use session primaryModel. No
28
+ * per-thread model override is accepted.
29
+ */
30
+
31
+ import { Engine } from '../engine.js';
32
+ import { MAIN_THREAD_ID } from './store.js';
33
+
34
+ export class EngineInstance {
35
+ /** @type {string} */
36
+ #threadId;
37
+
38
+ /** @type {Engine} */
39
+ #engine;
40
+
41
+ /** @type {Array<object>} owned per-thread conversation messages */
42
+ #messages;
43
+
44
+ /** @type {string} memory scope ref — today simply the threadId */
45
+ #memoryScope;
46
+
47
+ /** @type {boolean} */
48
+ #terminated = false;
49
+
50
+ /**
51
+ * @param {{
52
+ * threadId: string,
53
+ * engine: Engine,
54
+ * memoryScope?: string,
55
+ * initialMessages?: Array<object>,
56
+ * }} params
57
+ */
58
+ constructor({ threadId, engine, memoryScope, initialMessages }) {
59
+ if (!threadId || typeof threadId !== 'string') {
60
+ throw new Error('EngineInstance: threadId is required');
61
+ }
62
+ if (!engine) {
63
+ throw new Error('EngineInstance: engine is required');
64
+ }
65
+ this.#threadId = threadId;
66
+ this.#engine = engine;
67
+ this.#memoryScope = memoryScope || threadId;
68
+ this.#messages = Array.isArray(initialMessages) ? [...initialMessages] : [];
69
+ }
70
+
71
+ /** @returns {string} */
72
+ get threadId() { return this.#threadId; }
73
+
74
+ /** @returns {string} */
75
+ get memoryScope() { return this.#memoryScope; }
76
+
77
+ /** @returns {boolean} */
78
+ get terminated() { return this.#terminated; }
79
+
80
+ /** Number of messages recorded on this instance. */
81
+ get messageCount() { return this.#messages.length; }
82
+
83
+ /** Snapshot of the current messages array (copy, safe for callers). */
84
+ get messages() { return [...this.#messages]; }
85
+
86
+ /** Underlying Engine (for tool registration, trace access, etc.). */
87
+ get engine() { return this.#engine; }
88
+
89
+ /**
90
+ * Run a query on this thread's engine. Yields events tagged with this
91
+ * instance's bound threadId. After the run, user + assistant messages
92
+ * are appended to the owned messages array.
93
+ *
94
+ * @param {object} params
95
+ * @param {string} params.prompt
96
+ * @param {'dream'} [params.mode]
97
+ * @param {AbortSignal} [params.signal]
98
+ * @yields {object} EngineEvent with { ...event, threadId }
99
+ */
100
+ async *query({ prompt, mode, signal }) {
101
+ if (this.#terminated) {
102
+ yield {
103
+ type: 'error',
104
+ threadId: this.#threadId,
105
+ error: new Error(`EngineInstance(${this.#threadId}) has been terminated`),
106
+ retryable: false,
107
+ };
108
+ return;
109
+ }
110
+
111
+ // Snapshot of messages passed to the engine; the engine treats this
112
+ // as read-only (it builds its own conversation array internally).
113
+ const snapshot = [...this.#messages];
114
+ let assistantText = '';
115
+ const assistantToolCalls = [];
116
+
117
+ for await (const event of this.#engine.query({ prompt, mode, messages: snapshot, signal })) {
118
+ // Re-tag every event with the bound threadId. Non-object events
119
+ // (shouldn't happen — all engine events are objects) are passed
120
+ // through untouched.
121
+ const tagged = event && typeof event === 'object'
122
+ ? { ...event, threadId: this.#threadId }
123
+ : event;
124
+ yield tagged;
125
+
126
+ // Track assistant reply to persist after stream ends. Only tag the
127
+ // natural stream types — not our injected turn_start/turn_end.
128
+ if (event && typeof event === 'object') {
129
+ if (event.type === 'text_delta' && typeof event.text === 'string') {
130
+ assistantText += event.text;
131
+ } else if (event.type === 'tool_call') {
132
+ assistantToolCalls.push({ id: event.id, name: event.name, input: event.input });
133
+ }
134
+ }
135
+ }
136
+
137
+ // Append user + assistant to the owned messages array so subsequent
138
+ // queries on this thread carry conversational context.
139
+ this.#messages.push({ role: 'user', content: prompt });
140
+ const assistantMsg = { role: 'assistant', content: assistantText };
141
+ if (assistantToolCalls.length > 0) {
142
+ assistantMsg.toolCalls = assistantToolCalls;
143
+ }
144
+ this.#messages.push(assistantMsg);
145
+ }
146
+
147
+ /**
148
+ * Terminate this engine instance. Further .query() calls will emit an
149
+ * error event and return early. Does NOT tear down the underlying
150
+ * Engine (engines are shared across instances via composition from
151
+ * the registry — only the instance's per-thread state is dropped).
152
+ */
153
+ terminate() {
154
+ this.#terminated = true;
155
+ this.#messages = [];
156
+ }
157
+
158
+ /**
159
+ * Reset the owned messages array. Used by the registry for crash
160
+ * recovery / test cleanup. Does NOT terminate the instance.
161
+ * @param {Array<object>} [messages=[]]
162
+ */
163
+ resetMessages(messages = []) {
164
+ this.#messages = Array.isArray(messages) ? [...messages] : [];
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Factory helper — builds an EngineInstance that owns a fresh Engine,
170
+ * sharing the given dependency bag across all threads of a session.
171
+ *
172
+ * @param {{
173
+ * threadId: string,
174
+ * adapter: object,
175
+ * trace: object,
176
+ * config: object,
177
+ * conversationStore?: object,
178
+ * memoryStore?: object,
179
+ * toolRegistry?: object,
180
+ * skillManager?: object,
181
+ * mcpManager?: object,
182
+ * yeaftDir?: string,
183
+ * initialMessages?: Array<object>,
184
+ * }} deps
185
+ * @returns {EngineInstance}
186
+ */
187
+ export function createEngineInstance(deps) {
188
+ const {
189
+ threadId,
190
+ adapter,
191
+ trace,
192
+ config,
193
+ conversationStore,
194
+ memoryStore,
195
+ toolRegistry,
196
+ skillManager,
197
+ mcpManager,
198
+ yeaftDir,
199
+ initialMessages,
200
+ } = deps;
201
+ const engine = new Engine({
202
+ adapter,
203
+ trace,
204
+ config,
205
+ conversationStore,
206
+ memoryStore,
207
+ toolRegistry,
208
+ skillManager,
209
+ mcpManager,
210
+ yeaftDir,
211
+ });
212
+ return new EngineInstance({
213
+ threadId: threadId || MAIN_THREAD_ID,
214
+ engine,
215
+ memoryScope: threadId || MAIN_THREAD_ID,
216
+ initialMessages,
217
+ });
218
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * engine-registry.js — task-308 Phase 2.
3
+ *
4
+ * A ThreadEngineRegistry is a per-session Map<threadId, EngineInstance>.
5
+ * It owns instance lifecycles:
6
+ *
7
+ * - `get(threadId)` returns the existing instance for a thread.
8
+ * - `ensure(threadId, opts)` lazily creates one via the configured
9
+ * factory if it does not yet exist. This is the primary entry point
10
+ * for routing a user message to the correct thread engine.
11
+ * - `listActive()` enumerates non-terminated instances, useful for
12
+ * the web-bridge to show "active threads" indicators.
13
+ * - `terminate(threadId)` tears down a single thread engine without
14
+ * disturbing the rest.
15
+ * - `terminateAll()` is called on session shutdown.
16
+ *
17
+ * The registry holds no LLM/tool state itself — it delegates to the
18
+ * factory, which in production will be the closure over the shared
19
+ * session deps (adapter, trace, config, stores, tool registry, …).
20
+ *
21
+ * Concurrency note: Node's single-threaded event loop means the
22
+ * registry's Map mutations are race-free. Concurrent .query() calls
23
+ * interleave only at await points, and each EngineInstance keeps its
24
+ * per-turn state inside the async generator's local scope, not on
25
+ * `this` — so two threads can stream simultaneously without stepping
26
+ * on each other.
27
+ */
28
+
29
+ import { MAIN_THREAD_ID } from './store.js';
30
+ import { createEngineInstance } from './engine-instance.js';
31
+
32
+ export class ThreadEngineRegistry {
33
+ /** @type {Map<string, import('./engine-instance.js').EngineInstance>} */
34
+ #instances;
35
+
36
+ /** @type {(threadId: string, opts?: object) => import('./engine-instance.js').EngineInstance} */
37
+ #factory;
38
+
39
+ /** @type {string} */
40
+ #currentThreadId;
41
+
42
+ /**
43
+ * @param {{
44
+ * factory: (threadId: string, opts?: object) => import('./engine-instance.js').EngineInstance,
45
+ * }} params
46
+ */
47
+ constructor({ factory } = {}) {
48
+ if (typeof factory !== 'function') {
49
+ throw new Error('ThreadEngineRegistry: factory function is required');
50
+ }
51
+ this.#instances = new Map();
52
+ this.#factory = factory;
53
+ this.#currentThreadId = MAIN_THREAD_ID;
54
+ }
55
+
56
+ /** @returns {string} */
57
+ get currentThreadId() { return this.#currentThreadId; }
58
+
59
+ /** Total number of registered (including terminated) instances. */
60
+ get size() { return this.#instances.size; }
61
+
62
+ /**
63
+ * Get an existing instance for a thread. Returns null if not yet
64
+ * created. Does NOT lazy-create — use ensure() for that.
65
+ * @param {string} threadId
66
+ */
67
+ get(threadId) {
68
+ return this.#instances.get(threadId) || null;
69
+ }
70
+
71
+ /**
72
+ * Lazy-get-or-create an instance for a thread. If one already exists
73
+ * and is not terminated, it is returned; if it was terminated, a new
74
+ * one replaces it. Any `opts` are forwarded to the factory.
75
+ *
76
+ * @param {string} threadId
77
+ * @param {object} [opts]
78
+ * @returns {import('./engine-instance.js').EngineInstance}
79
+ */
80
+ ensure(threadId, opts) {
81
+ if (!threadId || typeof threadId !== 'string') {
82
+ throw new Error('ThreadEngineRegistry.ensure: threadId required');
83
+ }
84
+ const existing = this.#instances.get(threadId);
85
+ if (existing && !existing.terminated) return existing;
86
+ const instance = this.#factory(threadId, opts);
87
+ if (!instance || typeof instance.query !== 'function') {
88
+ throw new Error(`ThreadEngineRegistry.ensure: factory did not return an EngineInstance for ${threadId}`);
89
+ }
90
+ this.#instances.set(threadId, instance);
91
+ return instance;
92
+ }
93
+
94
+ /**
95
+ * Set the current thread marker. Does not lazy-create — caller must
96
+ * ensure() if they want an instance for an unseen thread.
97
+ * @param {string} threadId
98
+ */
99
+ setCurrent(threadId) {
100
+ if (!threadId || typeof threadId !== 'string') {
101
+ throw new Error('ThreadEngineRegistry.setCurrent: threadId required');
102
+ }
103
+ this.#currentThreadId = threadId;
104
+ }
105
+
106
+ /**
107
+ * List all non-terminated instances. The order is insertion order.
108
+ * @returns {Array<import('./engine-instance.js').EngineInstance>}
109
+ */
110
+ listActive() {
111
+ const out = [];
112
+ for (const inst of this.#instances.values()) {
113
+ if (!inst.terminated) out.push(inst);
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * All instances including terminated ones (for inspection / tests).
120
+ * @returns {Array<import('./engine-instance.js').EngineInstance>}
121
+ */
122
+ listAll() {
123
+ return [...this.#instances.values()];
124
+ }
125
+
126
+ /**
127
+ * Terminate a single thread's instance. Safe on unknown threadId.
128
+ * @param {string} threadId
129
+ * @returns {boolean} true if a live instance was terminated
130
+ */
131
+ terminate(threadId) {
132
+ const inst = this.#instances.get(threadId);
133
+ if (!inst) return false;
134
+ if (inst.terminated) return false;
135
+ inst.terminate();
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Terminate all instances. Used on session shutdown.
141
+ * @returns {number} count terminated
142
+ */
143
+ terminateAll() {
144
+ let n = 0;
145
+ for (const inst of this.#instances.values()) {
146
+ if (!inst.terminated) {
147
+ inst.terminate();
148
+ n += 1;
149
+ }
150
+ }
151
+ return n;
152
+ }
153
+
154
+ /**
155
+ * Remove a thread's instance from the map entirely. The registry
156
+ * will no longer return it from listAll / listActive. Primarily used
157
+ * after terminate() when the caller wants a full forget.
158
+ * @param {string} threadId
159
+ * @returns {boolean}
160
+ */
161
+ delete(threadId) {
162
+ const inst = this.#instances.get(threadId);
163
+ if (!inst) return false;
164
+ if (!inst.terminated) inst.terminate();
165
+ return this.#instances.delete(threadId);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Build a registry whose factory constructs full Engine instances using
171
+ * a shared dependency bag. This is the production entry point used by
172
+ * session.js:
173
+ *
174
+ * const registry = createThreadEngineRegistry({
175
+ * adapter, trace, config, conversationStore, memoryStore,
176
+ * toolRegistry, skillManager, mcpManager, yeaftDir,
177
+ * });
178
+ * const inst = registry.ensure(threadId);
179
+ * for await (const event of inst.query({ prompt })) { ... }
180
+ *
181
+ * @param {object} deps — shared session deps (see session.js §9)
182
+ * @returns {ThreadEngineRegistry}
183
+ */
184
+ export function createThreadEngineRegistry(deps) {
185
+ return new ThreadEngineRegistry({
186
+ factory: (threadId, opts = {}) => createEngineInstance({
187
+ ...deps,
188
+ ...opts,
189
+ threadId,
190
+ }),
191
+ });
192
+ }
@@ -6,8 +6,14 @@
6
6
  * filesystem layer is merged. When task-298 merges, this module will be
7
7
  * replaced (or promoted to a shim) by a file-backed store with the same API.
8
8
  *
9
+ * Cached fields (task-299 rework, prev-2 suggestion):
10
+ * - messageCount, lastMessageAt, archived are maintained incrementally via
11
+ * noteMessage()/archive()/setStatus() so that ListThreads does NOT need
12
+ * to scan every message on each call. A rebuildFromMessages(messages)
13
+ * helper exists for sanity / crash-recovery reconciliation.
14
+ *
9
15
  * Responsibilities (Phase 1):
10
- * - Maintain a map of threadId → thread metadata.
16
+ * - Maintain a map of threadId → thread metadata + cached counters.
11
17
  * - Track a "currentThreadId" marker for the engine.
12
18
  * - Maintain attachments from threadId → taskId.
13
19
  *
@@ -20,12 +26,19 @@ import { randomUUID } from 'crypto';
20
26
  /** Default / root thread id — every fresh ThreadStore has one. */
21
27
  export const MAIN_THREAD_ID = 'main';
22
28
 
29
+ /** Valid thread status values. Mirrors design doc §5. */
30
+ export const THREAD_STATUSES = ['active', 'idle', 'archived'];
31
+
23
32
  /**
24
33
  * @typedef {Object} Thread
25
34
  * @property {string} id
26
35
  * @property {string} name
27
36
  * @property {string} [goal]
28
37
  * @property {string|null} parentThreadId
38
+ * @property {'active'|'idle'|'archived'} status — cached; initial 'active'
39
+ * @property {number} messageCount — cached counter, incremented via noteMessage
40
+ * @property {number|null} lastMessageAt — cached timestamp of last noted message
41
+ * @property {boolean} archived — convenience mirror of (status === 'archived')
29
42
  * @property {number} createdAt
30
43
  * @property {number} updatedAt
31
44
  */
@@ -45,16 +58,29 @@ export class ThreadStore {
45
58
  this.#attachments = new Map();
46
59
 
47
60
  const now = Date.now();
48
- const main = {
61
+ this.#threads.set(MAIN_THREAD_ID, this.#newThreadRecord({
49
62
  id: MAIN_THREAD_ID,
50
63
  name: 'main',
51
64
  goal: '',
52
65
  parentThreadId: null,
53
66
  createdAt: now,
54
67
  updatedAt: now,
68
+ }));
69
+ this.#currentId = MAIN_THREAD_ID;
70
+ }
71
+
72
+ /** Internal: build a thread record with default cached fields. */
73
+ #newThreadRecord(base) {
74
+ return {
75
+ status: 'active',
76
+ messageCount: 0,
77
+ lastMessageAt: null,
78
+ lastActivityAt: null, // task-300 sidebar: latest of lastMessageAt/updatedAt
79
+ archived: false,
80
+ unread: 0, // task-300 sidebar: messages since last read marker
81
+ preview: '', // task-300 sidebar: short excerpt of latest content
82
+ ...base,
55
83
  };
56
- this.#threads.set(main.id, main);
57
- this.#currentId = main.id;
58
84
  }
59
85
 
60
86
  /** Get current thread id (defaults to 'main'). */
@@ -81,14 +107,14 @@ export class ThreadStore {
81
107
  }
82
108
  const id = `thr-${randomUUID().slice(0, 8)}`;
83
109
  const now = Date.now();
84
- const thread = {
110
+ const thread = this.#newThreadRecord({
85
111
  id,
86
112
  name: name.trim(),
87
113
  goal: goal || '',
88
114
  parentThreadId: parentThreadId || null,
89
115
  createdAt: now,
90
116
  updatedAt: now,
91
- };
117
+ });
92
118
  this.#threads.set(id, thread);
93
119
  return thread;
94
120
  }
@@ -121,6 +147,112 @@ export class ThreadStore {
121
147
  t.updatedAt = Date.now();
122
148
  }
123
149
 
150
+ /**
151
+ * Record that a message has been persisted on a thread. Increments the
152
+ * cached messageCount and updates lastMessageAt. Safe to call repeatedly;
153
+ * unknown threadIds are silently ignored (defense: bookkeeping must never
154
+ * block the main persist path).
155
+ *
156
+ * @param {string} threadId
157
+ * @param {number} [at=Date.now()]
158
+ */
159
+ noteMessage(threadId, at = Date.now(), opts = {}) {
160
+ const t = this.#threads.get(threadId);
161
+ if (!t) return;
162
+ t.messageCount += 1;
163
+ t.lastMessageAt = at;
164
+ t.lastActivityAt = at;
165
+ t.updatedAt = at;
166
+ // task-300 sidebar unread counter: any new message not originating from the
167
+ // user themselves counts as unread until markRead() is called. Callers may
168
+ // pass { countsAsUnread: false } (e.g. for user's own messages).
169
+ if (opts.countsAsUnread !== false) {
170
+ t.unread += 1;
171
+ }
172
+ // Short preview for sidebar hover / list (capped at 160 chars).
173
+ if (typeof opts.preview === 'string' && opts.preview.length > 0) {
174
+ const p = opts.preview.replace(/\s+/g, ' ').trim();
175
+ t.preview = p.length > 160 ? p.slice(0, 157) + '...' : p;
176
+ }
177
+ // Any activity bumps archived back to active.
178
+ if (t.status === 'archived') {
179
+ t.status = 'active';
180
+ t.archived = false;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Mark a thread as read — resets unread counter to 0. Safe on unknown id.
186
+ * @param {string} threadId
187
+ */
188
+ markRead(threadId) {
189
+ const t = this.#threads.get(threadId);
190
+ if (!t) return;
191
+ t.unread = 0;
192
+ }
193
+
194
+ /**
195
+ * Mark a thread archived. 'main' cannot be archived.
196
+ * @param {string} id
197
+ */
198
+ archive(id) {
199
+ const t = this.#threads.get(id);
200
+ if (!t) throw new Error(`thread not found: ${id}`);
201
+ if (id === MAIN_THREAD_ID) throw new Error('cannot archive main thread');
202
+ t.status = 'archived';
203
+ t.archived = true;
204
+ t.updatedAt = Date.now();
205
+ }
206
+
207
+ /**
208
+ * Set thread status explicitly. Must be one of THREAD_STATUSES.
209
+ * @param {string} id
210
+ * @param {'active'|'idle'|'archived'} status
211
+ */
212
+ setStatus(id, status) {
213
+ if (!THREAD_STATUSES.includes(status)) {
214
+ throw new Error(`invalid status: ${status}`);
215
+ }
216
+ const t = this.#threads.get(id);
217
+ if (!t) throw new Error(`thread not found: ${id}`);
218
+ if (id === MAIN_THREAD_ID && status === 'archived') {
219
+ throw new Error('cannot archive main thread');
220
+ }
221
+ t.status = status;
222
+ t.archived = status === 'archived';
223
+ t.updatedAt = Date.now();
224
+ }
225
+
226
+ /**
227
+ * Rebuild cached fields (messageCount/lastMessageAt) from a flat messages
228
+ * list. Used for crash recovery or as a sanity check in tests. Each
229
+ * message must have { threadId, createdAt? }; missing threadId is treated
230
+ * as MAIN_THREAD_ID (matches design doc §5 default).
231
+ *
232
+ * Counts per thread are reset to zero first to guarantee idempotency.
233
+ *
234
+ * @param {Array<{threadId?: string, createdAt?: number}>} messages
235
+ */
236
+ rebuildFromMessages(messages) {
237
+ // Reset counters
238
+ for (const t of this.#threads.values()) {
239
+ t.messageCount = 0;
240
+ t.lastMessageAt = null;
241
+ t.lastActivityAt = null;
242
+ }
243
+ for (const m of messages || []) {
244
+ const tid = m.threadId || MAIN_THREAD_ID;
245
+ const t = this.#threads.get(tid);
246
+ if (!t) continue;
247
+ t.messageCount += 1;
248
+ const ts = typeof m.createdAt === 'number' ? m.createdAt : Date.now();
249
+ if (!t.lastMessageAt || ts > t.lastMessageAt) {
250
+ t.lastMessageAt = ts;
251
+ t.lastActivityAt = ts;
252
+ }
253
+ }
254
+ }
255
+
124
256
  /**
125
257
  * Attach a task to a thread. Overwrites any existing attachment.
126
258
  * @param {string} threadId
@@ -61,8 +61,10 @@ import {
61
61
  switchThread,
62
62
  listThreads,
63
63
  attachThreadToTask,
64
+ spawnTask,
65
+ readThreadSummary,
66
+ readThreadRecent,
64
67
  } from './thread-tools.js';
65
- import { spawnTask, spawnSubtask } from './spawn-task-tools.js';
66
68
 
67
69
  // --- P2 Auxiliary tools ---
68
70
  import { jsRepl, jsReplReset } from './js-repl.js';
@@ -128,7 +130,8 @@ export const allTools = [
128
130
  listThreads,
129
131
  attachThreadToTask,
130
132
  spawnTask,
131
- spawnSubtask,
133
+ readThreadSummary,
134
+ readThreadRecent,
132
135
 
133
136
  // P2 Auxiliary
134
137
  jsRepl,
@@ -1,20 +1,25 @@
1
1
  /**
2
2
  * thread-tools.js — Thread-spawning tools for Unify Engine (Phase 1).
3
3
  *
4
- * Phase 1 scope (task-299):
5
- * - SpawnThread — create a new thread
6
- * - SwitchThread — set the engine's currentThreadId marker
7
- * - ListThreads — list threads + current marker
8
- * - AttachThreadToTask — bind a thread to an existing task
4
+ * Phase 1 scope (task-299 rework):
5
+ * - SpawnThread — create a new thread
6
+ * - SwitchThread — set the engine's currentThreadId marker
7
+ * - ListThreads — list threads + current marker + cached stats
8
+ * - AttachThreadToTask — bind a thread to an existing task
9
+ * - SpawnTask — create a task or subtask (parent_task_id optional)
10
+ * - ReadThreadSummary — cross-reference: summary of a thread (id/name/
11
+ * status/messageCount/lastMessageAt/task)
12
+ * - ReadThreadRecent — cross-reference: last N messages of a thread
9
13
  *
10
14
  * Phase 1 uses the in-memory ThreadStore (agent/unify/threads/store.js)
11
- * so these tools can ship and be tested before task-298's file-backed
12
- * data layer merges. The tool surface is designed to remain stable when
13
- * the store is replaced.
15
+ * and the existing ConversationStore for messages. When task-298 merges,
16
+ * ThreadStore becomes file-backed with the SAME API, so these tools
17
+ * continue to work unchanged.
14
18
  */
15
19
 
16
20
  import { defineTool } from './types.js';
17
- import { getThreadStore } from '../threads/store.js';
21
+ import { randomUUID } from 'crypto';
22
+ import { getThreadStore, MAIN_THREAD_ID } from '../threads/store.js';
18
23
  import { getTaskStore } from './task-tools.js';
19
24
 
20
25
  // ─── SpawnThread ─────────────────────────────────────────
@@ -57,6 +62,7 @@ engine to the new thread — call SwitchThread to activate it.`,
57
62
  name: t.name,
58
63
  goal: t.goal,
59
64
  parentThreadId: t.parentThreadId,
65
+ status: t.status,
60
66
  },
61
67
  message: `Thread created: ${t.name} (${t.id})`,
62
68
  });
@@ -107,7 +113,12 @@ thread.`,
107
113
 
108
114
  export const listThreads = defineTool({
109
115
  name: 'ListThreads',
110
- description: `List all threads and the engine's current thread marker.`,
116
+ description: `List all threads with cached status / messageCount / lastMessageAt.
117
+
118
+ Returns the data needed by the Phase 2 sidebar (task-300): each entry
119
+ exposes id, name, goal, parentThreadId, status ('active'|'idle'|'archived'),
120
+ messageCount, lastMessageAt, archived, attachedTaskId. Reads only cached
121
+ fields — does not scan messages.`,
111
122
  parameters: { type: 'object', properties: {} },
112
123
  modes: ['work'],
113
124
  isConcurrencySafe: () => true,
@@ -119,6 +130,13 @@ export const listThreads = defineTool({
119
130
  name: t.name,
120
131
  goal: t.goal,
121
132
  parentThreadId: t.parentThreadId,
133
+ status: t.status,
134
+ messageCount: t.messageCount,
135
+ lastMessageAt: t.lastMessageAt,
136
+ lastActivityAt: t.lastActivityAt ?? t.lastMessageAt,
137
+ archived: t.archived,
138
+ unread: t.unread || 0,
139
+ preview: t.preview || '',
122
140
  attachedTaskId: store.attachedTask(t.id),
123
141
  }));
124
142
  return JSON.stringify(
@@ -159,9 +177,6 @@ the same thread.`,
159
177
  if (!task_id) return JSON.stringify({ error: 'task_id is required' });
160
178
 
161
179
  const taskStore = getTaskStore();
162
- // If the task store is initialized, validate the task exists; if not
163
- // initialized (e.g. Phase 1 unit tests running before session bootstrap),
164
- // skip the task existence check — we still enforce thread existence.
165
180
  if (taskStore) {
166
181
  const task = taskStore.get(task_id);
167
182
  if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
@@ -181,3 +196,175 @@ the same thread.`,
181
196
  }
182
197
  },
183
198
  });
199
+
200
+ // ─── SpawnTask (merged — subtask via parent_task_id) ────
201
+
202
+ /**
203
+ * SpawnTask replaces both the old SpawnTask and the separate SpawnSubtask:
204
+ * pass `parent_task_id` when you want a subtask, omit it for top-level.
205
+ * Per prev-1 rework note: eliminating two tools with the same effect.
206
+ */
207
+ export const spawnTask = defineTool({
208
+ name: 'SpawnTask',
209
+ description: `Spawn a new task. Pass parent_task_id to create a subtask.
210
+
211
+ When parent_task_id is omitted → top-level task.
212
+ When parent_task_id is provided → subtask under that parent (parent must exist).
213
+ This replaces the deprecated SpawnSubtask tool.`,
214
+ parameters: {
215
+ type: 'object',
216
+ properties: {
217
+ title: { type: 'string' },
218
+ description: { type: 'string' },
219
+ priority: {
220
+ type: 'string',
221
+ enum: ['low', 'medium', 'high', 'critical'],
222
+ },
223
+ parent_task_id: {
224
+ type: 'string',
225
+ description: 'Optional parent task id; when present, a subtask is created',
226
+ },
227
+ },
228
+ required: ['title'],
229
+ },
230
+ modes: ['work'],
231
+ isConcurrencySafe: () => false,
232
+ isReadOnly: () => false,
233
+ async execute(input) {
234
+ const store = getTaskStore();
235
+ if (!store) {
236
+ return JSON.stringify({ error: 'Task store not initialized. Session may still be loading.' });
237
+ }
238
+ const { title, description = '', priority = 'medium', parent_task_id } = input || {};
239
+ if (!title) return JSON.stringify({ error: 'title is required' });
240
+
241
+ if (parent_task_id) {
242
+ const parent = store.get(parent_task_id);
243
+ if (!parent) return JSON.stringify({ error: `Parent task not found: ${parent_task_id}` });
244
+ }
245
+
246
+ const id = `task-${randomUUID().slice(0, 8)}`;
247
+ const now = Date.now();
248
+ store.create({
249
+ id,
250
+ title,
251
+ description,
252
+ priority,
253
+ status: 'pending',
254
+ parentId: parent_task_id || null,
255
+ parentTaskId: parent_task_id || null, // design §5 canonical field; kept in sync with parentId
256
+ createdAt: now,
257
+ updatedAt: now,
258
+ });
259
+ return JSON.stringify({
260
+ success: true,
261
+ task: {
262
+ id,
263
+ title,
264
+ priority,
265
+ status: 'pending',
266
+ parentTaskId: parent_task_id || null,
267
+ },
268
+ message: parent_task_id
269
+ ? `Subtask spawned: ${title} (${id}) under ${parent_task_id}`
270
+ : `Task spawned: ${title} (${id})`,
271
+ });
272
+ },
273
+ });
274
+
275
+ // ─── ReadThreadSummary (cross-reference, design §6 Q5) ──
276
+
277
+ export const readThreadSummary = defineTool({
278
+ name: 'ReadThreadSummary',
279
+ description: `Return a one-shot summary of a thread: id, name, goal, status,
280
+ messageCount, lastMessageAt, parentThreadId, attachedTaskId.
281
+
282
+ Use this to cross-reference work on another thread without switching.`,
283
+ parameters: {
284
+ type: 'object',
285
+ properties: {
286
+ thread_id: { type: 'string' },
287
+ },
288
+ required: ['thread_id'],
289
+ },
290
+ modes: ['work'],
291
+ isConcurrencySafe: () => true,
292
+ isReadOnly: () => true,
293
+ async execute(input) {
294
+ const { thread_id } = input || {};
295
+ if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
296
+ const store = getThreadStore();
297
+ const t = store.get(thread_id);
298
+ if (!t) return JSON.stringify({ error: `Thread not found: ${thread_id}` });
299
+ return JSON.stringify(
300
+ {
301
+ id: t.id,
302
+ name: t.name,
303
+ goal: t.goal,
304
+ status: t.status,
305
+ archived: t.archived,
306
+ messageCount: t.messageCount,
307
+ lastMessageAt: t.lastMessageAt,
308
+ parentThreadId: t.parentThreadId,
309
+ attachedTaskId: store.attachedTask(t.id),
310
+ createdAt: t.createdAt,
311
+ updatedAt: t.updatedAt,
312
+ },
313
+ null,
314
+ 2,
315
+ );
316
+ },
317
+ });
318
+
319
+ // ─── ReadThreadRecent (cross-reference, design §6 Q5) ───
320
+
321
+ export const readThreadRecent = defineTool({
322
+ name: 'ReadThreadRecent',
323
+ description: `Return the last N messages on a specific thread.
324
+
325
+ Requires an engine ConversationStore in context (ctx.conversationStore).
326
+ Reads conversation history and filters by threadId. Default N=20, max 200.
327
+ Use this to review another thread's recent activity without switching.`,
328
+ parameters: {
329
+ type: 'object',
330
+ properties: {
331
+ thread_id: { type: 'string' },
332
+ limit: { type: 'number', description: 'Max messages to return (default 20, max 200)' },
333
+ },
334
+ required: ['thread_id'],
335
+ },
336
+ modes: ['work'],
337
+ isConcurrencySafe: () => true,
338
+ isReadOnly: () => true,
339
+ async execute(input, ctx) {
340
+ const { thread_id, limit } = input || {};
341
+ if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
342
+ const store = getThreadStore();
343
+ if (!store.has(thread_id)) {
344
+ return JSON.stringify({ error: `Thread not found: ${thread_id}` });
345
+ }
346
+ const conv = ctx?.conversationStore;
347
+ if (!conv || typeof conv.loadRecent !== 'function') {
348
+ return JSON.stringify({
349
+ error: 'conversation store unavailable in tool context',
350
+ });
351
+ }
352
+ const cap = Math.max(1, Math.min(Number(limit) || 20, 200));
353
+ // Over-fetch then filter by thread, so N still applies post-filter.
354
+ const raw = conv.loadRecent(cap * 4);
355
+ const filtered = raw
356
+ .filter(m => (m.threadId || MAIN_THREAD_ID) === thread_id)
357
+ .slice(-cap)
358
+ .map(m => ({
359
+ role: m.role,
360
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
361
+ createdAt: m.createdAt || null,
362
+ threadId: m.threadId || MAIN_THREAD_ID,
363
+ }));
364
+ return JSON.stringify(
365
+ { threadId: thread_id, count: filtered.length, messages: filtered },
366
+ null,
367
+ 2,
368
+ );
369
+ },
370
+ });
@@ -24,7 +24,9 @@
24
24
  * @property {string} description — LLM-facing description
25
25
  * @property {object} parameters — JSON Schema for input
26
26
  * @property {(input: object, ctx?: ToolContext) => Promise<string>} execute — execution function
27
- * @property {string[]} modes — which modes can use this tool: ['chat', 'work']
27
+ * @property {string[]} [modes]@deprecated since task-297. Legacy mode filter (['chat', 'work']).
28
+ * Unify no longer has mode distinction; the ToolRegistry ignores this field and exposes every
29
+ * registered tool to the engine. Retained only so existing tool definitions keep loading.
28
30
  * @property {(input?: object) => boolean} [isConcurrencySafe] — can run in parallel?
29
31
  * @property {(input?: object) => boolean} [isReadOnly] — read-only operation?
30
32
  * @property {(input?: object) => boolean} [isDestructive] — destructive operation?
@@ -38,7 +40,7 @@
38
40
  * description: string,
39
41
  * parameters: object,
40
42
  * execute: (input: object, ctx?: ToolContext) => Promise<string>,
41
- * modes?: string[],
43
+ * modes?: string[], // @deprecated since task-297 — ignored by ToolRegistry
42
44
  * isConcurrencySafe?: (input?: object) => boolean,
43
45
  * isReadOnly?: (input?: object) => boolean,
44
46
  * isDestructive?: (input?: object) => boolean,
@@ -1,125 +0,0 @@
1
- /**
2
- * spawn-task-tools.js — Thin semantic-sugar wrappers over TaskCreate.
3
- *
4
- * Phase 1 of task-299: expose `SpawnTask` and `SpawnSubtask` as first-class
5
- * tool names so the LLM can "spawn" work items in the same vocabulary it
6
- * uses to spawn threads. Both delegate to the existing TaskStore (shared
7
- * with TaskCreate), so there is a single source of truth for task data.
8
- */
9
-
10
- import { defineTool } from './types.js';
11
- import { randomUUID } from 'crypto';
12
- import { getTaskStore } from './task-tools.js';
13
-
14
- function requireStore() {
15
- const store = getTaskStore();
16
- if (!store) return { error: 'Task store not initialized. Session may still be loading.' };
17
- return { store };
18
- }
19
-
20
- function newTaskId() {
21
- return `task-${randomUUID().slice(0, 8)}`;
22
- }
23
-
24
- // ─── SpawnTask ──────────────────────────────────────────
25
-
26
- export const spawnTask = defineTool({
27
- name: 'SpawnTask',
28
- description: `Spawn a new top-level task.
29
-
30
- Equivalent to TaskCreate without a parent. Provided as a first-class
31
- name so tasks and threads can be "spawned" with a uniform vocabulary.`,
32
- parameters: {
33
- type: 'object',
34
- properties: {
35
- title: { type: 'string' },
36
- description: { type: 'string' },
37
- priority: {
38
- type: 'string',
39
- enum: ['low', 'medium', 'high', 'critical'],
40
- },
41
- },
42
- required: ['title'],
43
- },
44
- modes: ['work'],
45
- isConcurrencySafe: () => false,
46
- isReadOnly: () => false,
47
- async execute(input) {
48
- const got = requireStore();
49
- if (got.error) return JSON.stringify({ error: got.error });
50
- const { title, description = '', priority = 'medium' } = input || {};
51
- if (!title) return JSON.stringify({ error: 'title is required' });
52
-
53
- const id = newTaskId();
54
- const now = Date.now();
55
- got.store.create({
56
- id,
57
- title,
58
- description,
59
- priority,
60
- status: 'pending',
61
- parentId: null,
62
- createdAt: now,
63
- updatedAt: now,
64
- });
65
- return JSON.stringify({
66
- success: true,
67
- task: { id, title, priority, status: 'pending' },
68
- message: `Task spawned: ${title} (${id})`,
69
- });
70
- },
71
- });
72
-
73
- // ─── SpawnSubtask ───────────────────────────────────────
74
-
75
- export const spawnSubtask = defineTool({
76
- name: 'SpawnSubtask',
77
- description: `Spawn a subtask under an existing parent task.
78
-
79
- Requires parent_task_id. The parent must exist. Use for breaking a
80
- larger task into executable pieces.`,
81
- parameters: {
82
- type: 'object',
83
- properties: {
84
- parent_task_id: { type: 'string' },
85
- title: { type: 'string' },
86
- description: { type: 'string' },
87
- priority: {
88
- type: 'string',
89
- enum: ['low', 'medium', 'high', 'critical'],
90
- },
91
- },
92
- required: ['parent_task_id', 'title'],
93
- },
94
- modes: ['work'],
95
- isConcurrencySafe: () => false,
96
- isReadOnly: () => false,
97
- async execute(input) {
98
- const got = requireStore();
99
- if (got.error) return JSON.stringify({ error: got.error });
100
- const { parent_task_id, title, description = '', priority = 'medium' } = input || {};
101
- if (!parent_task_id) return JSON.stringify({ error: 'parent_task_id is required' });
102
- if (!title) return JSON.stringify({ error: 'title is required' });
103
-
104
- const parent = got.store.get(parent_task_id);
105
- if (!parent) return JSON.stringify({ error: `Parent task not found: ${parent_task_id}` });
106
-
107
- const id = newTaskId();
108
- const now = Date.now();
109
- got.store.create({
110
- id,
111
- title,
112
- description: description || `Subtask of: ${parent.title}`,
113
- priority,
114
- status: 'pending',
115
- parentId: parent_task_id,
116
- createdAt: now,
117
- updatedAt: now,
118
- });
119
- return JSON.stringify({
120
- success: true,
121
- task: { id, title, priority, status: 'pending', parentId: parent_task_id },
122
- message: `Subtask spawned: ${title} (${id}) under ${parent_task_id}`,
123
- });
124
- },
125
- });