@yeaft/webchat-agent 0.1.483 → 0.1.485

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.483",
3
+ "version": "0.1.485",
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/session.js CHANGED
@@ -125,9 +125,9 @@ export async function loadSession(options = {}) {
125
125
  initTaskStore(yeaftDir, { readOnly: config._readOnly || false });
126
126
 
127
127
  // ─── 5b. Initialize thread store (task-299 Phase 1) ────
128
- // In-memory only for Phase 1; replaced by a file-backed store
129
- // when task-298's data layer merges.
130
- initThreadStore();
128
+ // task-307a: now file-backed under ~/.yeaft/threads/. Passing the
129
+ // yeaftDir switches on disk persistence; read-only mode is honoured.
130
+ initThreadStore(yeaftDir, { readOnly: config._readOnly || false, force: true });
131
131
 
132
132
  // ─── 6. Load skills ────────────────────────────────────
133
133
  let skillManager;
@@ -1,26 +1,49 @@
1
1
  /**
2
- * store.js — In-memory ThreadStore for Yeaft Unify (Phase 1 mock).
2
+ * store.js — File-backed ThreadStore for Yeaft Unify (task-307a).
3
3
  *
4
- * Phase 1 intentionally keeps the implementation in-memory only: it lets the
5
- * thread/task spawn tools ship and be tested before task-298's real
6
- * filesystem layer is merged. When task-298 merges, this module will be
7
- * replaced (or promoted to a shim) by a file-backed store with the same API.
4
+ * Promotes the Phase-1 in-memory stub to persist threads under
5
+ * `~/.yeaft/threads/` so conversation structure survives agent restarts.
6
+ * API is backward compatible with the task-299 canonical surface callers
7
+ * that used the in-memory version keep working without change, but a new
8
+ * optional `yeaftDir` argument (to the constructor / `initThreadStore`)
9
+ * switches persistence on.
8
10
  *
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.
11
+ * On-disk layout:
12
+ * ~/.yeaft/threads/
13
+ * index.md — Auto-generated overview (current thread id
14
+ * + attachments table + thread summary list).
15
+ * {threadId}.md — One markdown file per thread. YAML
16
+ * frontmatter holds every cached field, the
17
+ * body is the short preview.
14
18
  *
15
- * Responsibilities (Phase 1):
16
- * - Maintain a map of threadId thread metadata + cached counters.
17
- * - Track a "currentThreadId" marker for the engine.
18
- * - Maintain attachments from threadId taskId.
19
+ * Write semantics:
20
+ * - Every mutation schedules a debounced flush (8 ms) of the set of dirty
21
+ * thread files and, when something changed, the index. A synchronous
22
+ * `flush()` is exposed for tests and graceful shutdown.
23
+ * - On construction we load any existing `{id}.md` files and rebuild the
24
+ * in-memory map + attachments, so round-trips are a simple "close /
25
+ * reopen".
26
+ * - Read-only mode (e.g. when `~/.yeaft/` is not writable) silently skips
27
+ * all filesystem writes — in-memory behaviour is preserved.
19
28
  *
20
- * A single "main" thread is created on construction so that pre-spawn
21
- * messages have a valid threadId to carry.
29
+ * Cached fields (task-299 contract, preserved verbatim):
30
+ * messageCount / lastMessageAt / lastActivityAt / archived / unread /
31
+ * preview. These all persist to the YAML frontmatter so ListThreads never
32
+ * needs to scan messages after a restart.
33
+ *
34
+ * A single "main" thread is always present after init — either loaded from
35
+ * disk or synthesised as a fresh record when the directory is empty.
22
36
  */
23
37
 
38
+ import {
39
+ existsSync,
40
+ mkdirSync,
41
+ readdirSync,
42
+ readFileSync,
43
+ unlinkSync,
44
+ writeFileSync,
45
+ } from 'fs';
46
+ import { join } from 'path';
24
47
  import { randomUUID } from 'crypto';
25
48
 
26
49
  /** Default / root thread id — every fresh ThreadStore has one. */
@@ -29,16 +52,170 @@ export const MAIN_THREAD_ID = 'main';
29
52
  /** Valid thread status values. Mirrors design doc §5. */
30
53
  export const THREAD_STATUSES = ['active', 'idle', 'archived'];
31
54
 
55
+ /** Debounce window for grouped disk writes. Kept short so tests don't hang. */
56
+ const FLUSH_DEBOUNCE_MS = 8;
57
+
58
+ // ─── YAML (de)serialisation ──────────────────────────────────────────────
59
+
60
+ /**
61
+ * Serialise a thread record to Markdown with YAML frontmatter.
62
+ *
63
+ * Only scalar / boolean / number / null frontmatter values are emitted.
64
+ * Strings that span multiple lines or contain leading whitespace get folded
65
+ * into a single-line value (threads' free-text goes in the body).
66
+ *
67
+ * @param {object} t
68
+ * @returns {string}
69
+ */
70
+ function serializeThread(t) {
71
+ const fm = [
72
+ '---',
73
+ `id: ${t.id}`,
74
+ `name: ${escapeScalar(t.name)}`,
75
+ `goal: ${escapeScalar(t.goal || '')}`,
76
+ `parentThreadId: ${t.parentThreadId == null ? 'null' : t.parentThreadId}`,
77
+ `status: ${t.status}`,
78
+ `archived: ${t.archived ? 'true' : 'false'}`,
79
+ `messageCount: ${t.messageCount | 0}`,
80
+ `lastMessageAt: ${t.lastMessageAt == null ? 'null' : t.lastMessageAt}`,
81
+ `lastActivityAt: ${t.lastActivityAt == null ? 'null' : t.lastActivityAt}`,
82
+ `unread: ${t.unread | 0}`,
83
+ `createdAt: ${t.createdAt}`,
84
+ `updatedAt: ${t.updatedAt}`,
85
+ '---',
86
+ '',
87
+ ];
88
+ // Body is the preview (wrapped so the file remains human-readable).
89
+ if (t.preview) fm.push(t.preview);
90
+ return fm.join('\n') + '\n';
91
+ }
92
+
93
+ function escapeScalar(v) {
94
+ if (v == null) return '';
95
+ // Keep on one physical line; any embedded newline becomes a space so YAML
96
+ // stays flat.
97
+ return String(v).replace(/\s+/g, ' ').trim();
98
+ }
99
+
100
+ /**
101
+ * Parse the markdown-with-frontmatter produced by `serializeThread`. Returns
102
+ * null when the file is malformed; callers should skip it silently so one
103
+ * corrupt thread file never blocks recovery of the rest.
104
+ *
105
+ * @param {string} raw
106
+ * @returns {object|null}
107
+ */
108
+ function parseThread(raw) {
109
+ if (!raw || !raw.startsWith('---')) return null;
110
+ const end = raw.indexOf('\n---', 3);
111
+ if (end === -1) return null;
112
+ const fm = raw.slice(3, end).trim();
113
+ const body = raw.slice(end + 4).replace(/^\n/, '').trimEnd();
114
+ const record = {};
115
+ for (const line of fm.split('\n')) {
116
+ const idx = line.indexOf(':');
117
+ if (idx === -1) continue;
118
+ const key = line.slice(0, idx).trim();
119
+ const rawVal = line.slice(idx + 1).trim();
120
+ if (!key) continue;
121
+ if (rawVal === 'null' || rawVal === '') {
122
+ record[key] = null;
123
+ } else if (rawVal === 'true' || rawVal === 'false') {
124
+ record[key] = rawVal === 'true';
125
+ } else if (/^-?\d+$/.test(rawVal)) {
126
+ record[key] = parseInt(rawVal, 10);
127
+ } else {
128
+ record[key] = rawVal;
129
+ }
130
+ }
131
+ if (!record.id || !record.name) return null;
132
+ // Default to safe values if the file pre-dates a field.
133
+ if (!THREAD_STATUSES.includes(record.status)) record.status = 'active';
134
+ record.archived = record.status === 'archived';
135
+ record.messageCount = Number.isFinite(record.messageCount) ? record.messageCount : 0;
136
+ record.unread = Number.isFinite(record.unread) ? record.unread : 0;
137
+ record.preview = body;
138
+ record.lastActivityAt = record.lastActivityAt ?? record.lastMessageAt ?? null;
139
+ return record;
140
+ }
141
+
142
+ /**
143
+ * Generate `index.md` — a human-readable roll-up of all threads in the
144
+ * store, the current thread marker, and attachments. Parsers should NOT
145
+ * depend on this file; it exists for human inspection and crash-triage.
146
+ */
147
+ function generateIndex(threads, currentId, attachments) {
148
+ const now = new Date().toISOString();
149
+ const lines = [
150
+ '---',
151
+ `currentId: ${currentId}`,
152
+ `totalThreads: ${threads.size}`,
153
+ `lastUpdated: ${now}`,
154
+ '---',
155
+ '# Thread Index',
156
+ '',
157
+ '| ID | Name | Status | Messages | Last Activity |',
158
+ '|----|------|--------|----------|---------------|',
159
+ ];
160
+ for (const t of threads.values()) {
161
+ const stamp = t.lastActivityAt
162
+ ? new Date(t.lastActivityAt).toISOString().slice(0, 19).replace('T', ' ')
163
+ : '-';
164
+ lines.push(`| ${t.id} | ${t.name} | ${t.status} | ${t.messageCount} | ${stamp} |`);
165
+ }
166
+ if (attachments.size > 0) {
167
+ lines.push('');
168
+ lines.push('## Attachments');
169
+ lines.push('');
170
+ lines.push('| Thread | Task |');
171
+ lines.push('|--------|------|');
172
+ for (const [threadId, taskId] of attachments.entries()) {
173
+ lines.push(`| ${threadId} | ${taskId} |`);
174
+ }
175
+ }
176
+ return lines.join('\n') + '\n';
177
+ }
178
+
179
+ /**
180
+ * Serialise the attachments map to a stable JSON payload (array form so key
181
+ * order is preserved on reload). Stored separately from `index.md` so the
182
+ * human-readable index stays cosmetic.
183
+ */
184
+ function serializeAttachments(attachments) {
185
+ return JSON.stringify(
186
+ [...attachments.entries()].map(([threadId, taskId]) => ({ threadId, taskId })),
187
+ null,
188
+ 2,
189
+ ) + '\n';
190
+ }
191
+
192
+ function parseAttachments(raw) {
193
+ try {
194
+ const arr = JSON.parse(raw);
195
+ if (!Array.isArray(arr)) return [];
196
+ return arr.filter(
197
+ (e) => e && typeof e.threadId === 'string' && typeof e.taskId === 'string',
198
+ );
199
+ } catch {
200
+ return [];
201
+ }
202
+ }
203
+
204
+ // ─── ThreadStore class ───────────────────────────────────────────────────
205
+
32
206
  /**
33
207
  * @typedef {Object} Thread
34
208
  * @property {string} id
35
209
  * @property {string} name
36
210
  * @property {string} [goal]
37
211
  * @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')
212
+ * @property {'active'|'idle'|'archived'} status
213
+ * @property {number} messageCount
214
+ * @property {number|null} lastMessageAt
215
+ * @property {number|null} lastActivityAt
216
+ * @property {boolean} archived
217
+ * @property {number} unread
218
+ * @property {string} preview
42
219
  * @property {number} createdAt
43
220
  * @property {number} updatedAt
44
221
  */
@@ -46,53 +223,243 @@ export const THREAD_STATUSES = ['active', 'idle', 'archived'];
46
223
  export class ThreadStore {
47
224
  /** @type {Map<string, Thread>} */
48
225
  #threads;
49
-
50
226
  /** @type {string} */
51
227
  #currentId;
52
-
53
228
  /** @type {Map<string, string>} threadId → taskId */
54
229
  #attachments;
55
230
 
56
- constructor() {
231
+ /** @type {string|null} */
232
+ #dir;
233
+ /** @type {string|null} */
234
+ #indexPath;
235
+ /** @type {string|null} */
236
+ #attachmentsPath;
237
+ /** @type {boolean} */
238
+ #readOnly;
239
+ /** @type {Set<string>} dirty thread ids pending flush */
240
+ #dirtyThreads;
241
+ /** @type {boolean} */
242
+ #dirtyIndex;
243
+ /** @type {boolean} */
244
+ #dirtyAttachments;
245
+ /** @type {any} NodeJS.Timeout */
246
+ #flushTimer;
247
+
248
+ /**
249
+ * @param {string} [yeaftDir] — Base ~/.yeaft directory. Omit for in-memory mode.
250
+ * @param {{ readOnly?: boolean }} [opts]
251
+ */
252
+ constructor(yeaftDir, opts = {}) {
57
253
  this.#threads = new Map();
58
254
  this.#attachments = new Map();
59
-
60
- const now = Date.now();
61
- this.#threads.set(MAIN_THREAD_ID, this.#newThreadRecord({
62
- id: MAIN_THREAD_ID,
63
- name: 'main',
64
- goal: '',
65
- parentThreadId: null,
66
- createdAt: now,
67
- updatedAt: now,
68
- }));
69
255
  this.#currentId = MAIN_THREAD_ID;
256
+ this.#dirtyThreads = new Set();
257
+ this.#dirtyIndex = false;
258
+ this.#dirtyAttachments = false;
259
+ this.#flushTimer = null;
260
+
261
+ this.#readOnly = !!opts.readOnly;
262
+ if (yeaftDir) {
263
+ this.#dir = join(yeaftDir, 'threads');
264
+ this.#indexPath = join(this.#dir, 'index.md');
265
+ this.#attachmentsPath = join(this.#dir, 'attachments.json');
266
+ if (!this.#readOnly) {
267
+ try {
268
+ if (!existsSync(this.#dir)) mkdirSync(this.#dir, { recursive: true });
269
+ } catch {
270
+ this.#readOnly = true;
271
+ }
272
+ }
273
+ this.#loadAll();
274
+ } else {
275
+ this.#dir = null;
276
+ this.#indexPath = null;
277
+ this.#attachmentsPath = null;
278
+ }
279
+
280
+ // Ensure the main thread is always present.
281
+ if (!this.#threads.has(MAIN_THREAD_ID)) {
282
+ const now = Date.now();
283
+ this.#threads.set(
284
+ MAIN_THREAD_ID,
285
+ this.#newThreadRecord({
286
+ id: MAIN_THREAD_ID,
287
+ name: 'main',
288
+ goal: '',
289
+ parentThreadId: null,
290
+ createdAt: now,
291
+ updatedAt: now,
292
+ }),
293
+ );
294
+ this.#markDirty(MAIN_THREAD_ID);
295
+ }
296
+
297
+ // If the on-disk currentId is unknown, fall back to main.
298
+ if (!this.#threads.has(this.#currentId)) {
299
+ this.#currentId = MAIN_THREAD_ID;
300
+ }
70
301
  }
71
302
 
72
- /** Internal: build a thread record with default cached fields. */
303
+ /** Build a thread record with default cached fields. */
73
304
  #newThreadRecord(base) {
74
305
  return {
75
306
  status: 'active',
76
307
  messageCount: 0,
77
308
  lastMessageAt: null,
78
- lastActivityAt: null, // task-300 sidebar: latest of lastMessageAt/updatedAt
309
+ lastActivityAt: null,
79
310
  archived: false,
80
- unread: 0, // task-300 sidebar: messages since last read marker
81
- preview: '', // task-300 sidebar: short excerpt of latest content
311
+ unread: 0,
312
+ preview: '',
82
313
  ...base,
83
314
  };
84
315
  }
85
316
 
86
- /** Get current thread id (defaults to 'main'). */
87
- get currentId() {
88
- return this.#currentId;
317
+ // ─── load / persist ────────────────────────────────────────────────
318
+
319
+ /** Load all thread files + attachments from disk into memory. */
320
+ #loadAll() {
321
+ if (!this.#dir || !existsSync(this.#dir)) return;
322
+ let entries;
323
+ try {
324
+ entries = readdirSync(this.#dir, { withFileTypes: true });
325
+ } catch {
326
+ return;
327
+ }
328
+ for (const entry of entries) {
329
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
330
+ if (entry.name === 'index.md') continue;
331
+ const id = entry.name.slice(0, -3);
332
+ try {
333
+ const raw = readFileSync(join(this.#dir, entry.name), 'utf8');
334
+ const parsed = parseThread(raw);
335
+ if (parsed && parsed.id === id) {
336
+ this.#threads.set(id, parsed);
337
+ }
338
+ } catch {
339
+ // Skip corrupt files silently.
340
+ }
341
+ }
342
+ // Attachments side-car.
343
+ try {
344
+ if (this.#attachmentsPath && existsSync(this.#attachmentsPath)) {
345
+ const raw = readFileSync(this.#attachmentsPath, 'utf8');
346
+ for (const { threadId, taskId } of parseAttachments(raw)) {
347
+ if (this.#threads.has(threadId)) {
348
+ this.#attachments.set(threadId, taskId);
349
+ }
350
+ }
351
+ }
352
+ } catch {
353
+ // Skip silently.
354
+ }
355
+ // Try to recover currentId from index.md frontmatter.
356
+ try {
357
+ if (this.#indexPath && existsSync(this.#indexPath)) {
358
+ const raw = readFileSync(this.#indexPath, 'utf8');
359
+ const m = raw.match(/^currentId:\s*(\S+)/m);
360
+ if (m && this.#threads.has(m[1])) {
361
+ this.#currentId = m[1];
362
+ }
363
+ }
364
+ } catch {
365
+ // Ignore — fall back to main on miss.
366
+ }
367
+ }
368
+
369
+ #markDirty(threadId) {
370
+ if (!this.#dir || this.#readOnly) return;
371
+ this.#dirtyThreads.add(threadId);
372
+ this.#dirtyIndex = true;
373
+ this.#scheduleFlush();
89
374
  }
90
375
 
91
- /** Total thread count (including 'main'). */
92
- get size() {
93
- return this.#threads.size;
376
+ #markAttachmentsDirty() {
377
+ if (!this.#dir || this.#readOnly) return;
378
+ this.#dirtyAttachments = true;
379
+ this.#dirtyIndex = true;
380
+ this.#scheduleFlush();
381
+ }
382
+
383
+ #scheduleFlush() {
384
+ if (this.#flushTimer || typeof setTimeout !== 'function') return;
385
+ this.#flushTimer = setTimeout(() => {
386
+ this.#flushTimer = null;
387
+ this.flush();
388
+ }, FLUSH_DEBOUNCE_MS);
389
+ // Do not hold the process open for a pending flush — if the Node loop
390
+ // has nothing else to do, the store still makes it through shutdown
391
+ // via explicit flush() or process exit handlers.
392
+ if (this.#flushTimer && typeof this.#flushTimer.unref === 'function') {
393
+ this.#flushTimer.unref();
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Write any pending dirty state to disk immediately. Safe to call on an
399
+ * in-memory or read-only store (it becomes a no-op). Returns the number of
400
+ * files written.
401
+ */
402
+ flush() {
403
+ if (!this.#dir || this.#readOnly) {
404
+ this.#dirtyThreads.clear();
405
+ this.#dirtyIndex = false;
406
+ this.#dirtyAttachments = false;
407
+ return 0;
408
+ }
409
+ let written = 0;
410
+ for (const id of this.#dirtyThreads) {
411
+ const t = this.#threads.get(id);
412
+ if (!t) {
413
+ // Deleted thread → remove the file if present.
414
+ try {
415
+ const p = join(this.#dir, `${id}.md`);
416
+ if (existsSync(p)) {
417
+ unlinkSync(p);
418
+ written += 1;
419
+ }
420
+ } catch {
421
+ // ignore
422
+ }
423
+ continue;
424
+ }
425
+ try {
426
+ writeFileSync(join(this.#dir, `${id}.md`), serializeThread(t), 'utf8');
427
+ written += 1;
428
+ } catch {
429
+ // Best-effort
430
+ }
431
+ }
432
+ this.#dirtyThreads.clear();
433
+ if (this.#dirtyAttachments) {
434
+ try {
435
+ writeFileSync(this.#attachmentsPath, serializeAttachments(this.#attachments), 'utf8');
436
+ } catch {
437
+ // ignore
438
+ }
439
+ this.#dirtyAttachments = false;
440
+ }
441
+ if (this.#dirtyIndex) {
442
+ try {
443
+ writeFileSync(this.#indexPath, generateIndex(this.#threads, this.#currentId, this.#attachments), 'utf8');
444
+ } catch {
445
+ // ignore
446
+ }
447
+ this.#dirtyIndex = false;
448
+ }
449
+ return written;
94
450
  }
95
451
 
452
+ // ─── Query API (unchanged) ─────────────────────────────────────────
453
+
454
+ get currentId() { return this.#currentId; }
455
+ get size() { return this.#threads.size; }
456
+
457
+ get(id) { return this.#threads.get(id) || null; }
458
+ list() { return [...this.#threads.values()]; }
459
+ has(id) { return this.#threads.has(id); }
460
+
461
+ // ─── Mutation API (all calls schedule a debounced flush) ───────────
462
+
96
463
  /**
97
464
  * Create a new thread.
98
465
  * @param {{ name: string, goal?: string, parentThreadId?: string }} spec
@@ -116,28 +483,11 @@ export class ThreadStore {
116
483
  updatedAt: now,
117
484
  });
118
485
  this.#threads.set(id, thread);
486
+ this.#markDirty(id);
119
487
  return thread;
120
488
  }
121
489
 
122
- /** @param {string} id */
123
- get(id) {
124
- return this.#threads.get(id) || null;
125
- }
126
-
127
- /** @returns {Thread[]} */
128
- list() {
129
- return [...this.#threads.values()];
130
- }
131
-
132
- /** @param {string} id */
133
- has(id) {
134
- return this.#threads.has(id);
135
- }
136
-
137
- /**
138
- * Set the current thread marker. Throws if unknown.
139
- * @param {string} id
140
- */
490
+ /** Set the current thread marker. Throws if unknown. */
141
491
  switch(id) {
142
492
  if (!this.#threads.has(id)) {
143
493
  throw new Error(`thread not found: ${id}`);
@@ -145,16 +495,13 @@ export class ThreadStore {
145
495
  this.#currentId = id;
146
496
  const t = this.#threads.get(id);
147
497
  t.updatedAt = Date.now();
498
+ this.#markDirty(id);
148
499
  }
149
500
 
150
501
  /**
151
502
  * Record that a message has been persisted on a thread. Increments the
152
503
  * 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()]
504
+ * unknown threadIds are silently ignored.
158
505
  */
159
506
  noteMessage(threadId, at = Date.now(), opts = {}) {
160
507
  const t = this.#threads.get(threadId);
@@ -163,38 +510,29 @@ export class ThreadStore {
163
510
  t.lastMessageAt = at;
164
511
  t.lastActivityAt = at;
165
512
  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
513
  if (opts.countsAsUnread !== false) {
170
514
  t.unread += 1;
171
515
  }
172
- // Short preview for sidebar hover / list (capped at 160 chars).
173
516
  if (typeof opts.preview === 'string' && opts.preview.length > 0) {
174
517
  const p = opts.preview.replace(/\s+/g, ' ').trim();
175
518
  t.preview = p.length > 160 ? p.slice(0, 157) + '...' : p;
176
519
  }
177
- // Any activity bumps archived back to active.
178
520
  if (t.status === 'archived') {
179
521
  t.status = 'active';
180
522
  t.archived = false;
181
523
  }
524
+ this.#markDirty(threadId);
182
525
  }
183
526
 
184
- /**
185
- * Mark a thread as read — resets unread counter to 0. Safe on unknown id.
186
- * @param {string} threadId
187
- */
527
+ /** Mark a thread as read — resets unread counter to 0. */
188
528
  markRead(threadId) {
189
529
  const t = this.#threads.get(threadId);
190
530
  if (!t) return;
531
+ if (t.unread === 0) return;
191
532
  t.unread = 0;
533
+ this.#markDirty(threadId);
192
534
  }
193
535
 
194
- /**
195
- * Mark a thread archived. 'main' cannot be archived.
196
- * @param {string} id
197
- */
198
536
  archive(id) {
199
537
  const t = this.#threads.get(id);
200
538
  if (!t) throw new Error(`thread not found: ${id}`);
@@ -202,13 +540,9 @@ export class ThreadStore {
202
540
  t.status = 'archived';
203
541
  t.archived = true;
204
542
  t.updatedAt = Date.now();
543
+ this.#markDirty(id);
205
544
  }
206
545
 
207
- /**
208
- * Set thread status explicitly. Must be one of THREAD_STATUSES.
209
- * @param {string} id
210
- * @param {'active'|'idle'|'archived'} status
211
- */
212
546
  setStatus(id, status) {
213
547
  if (!THREAD_STATUSES.includes(status)) {
214
548
  throw new Error(`invalid status: ${status}`);
@@ -221,24 +555,19 @@ export class ThreadStore {
221
555
  t.status = status;
222
556
  t.archived = status === 'archived';
223
557
  t.updatedAt = Date.now();
558
+ this.#markDirty(id);
224
559
  }
225
560
 
226
561
  /**
227
562
  * 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
563
+ * list. Used for crash recovery or as a sanity check in tests.
235
564
  */
236
565
  rebuildFromMessages(messages) {
237
- // Reset counters
238
566
  for (const t of this.#threads.values()) {
239
567
  t.messageCount = 0;
240
568
  t.lastMessageAt = null;
241
569
  t.lastActivityAt = null;
570
+ this.#markDirty(t.id);
242
571
  }
243
572
  for (const m of messages || []) {
244
573
  const tid = m.threadId || MAIN_THREAD_ID;
@@ -250,14 +579,10 @@ export class ThreadStore {
250
579
  t.lastMessageAt = ts;
251
580
  t.lastActivityAt = ts;
252
581
  }
582
+ this.#markDirty(tid);
253
583
  }
254
584
  }
255
585
 
256
- /**
257
- * Attach a task to a thread. Overwrites any existing attachment.
258
- * @param {string} threadId
259
- * @param {string} taskId
260
- */
261
586
  attachTask(threadId, taskId) {
262
587
  if (!this.#threads.has(threadId)) {
263
588
  throw new Error(`thread not found: ${threadId}`);
@@ -266,35 +591,46 @@ export class ThreadStore {
266
591
  throw new Error('taskId is required');
267
592
  }
268
593
  this.#attachments.set(threadId, taskId);
594
+ this.#markAttachmentsDirty();
269
595
  }
270
596
 
271
- /**
272
- * Get the taskId attached to a thread, if any.
273
- * @param {string} threadId
274
- * @returns {string|null}
275
- */
276
597
  attachedTask(threadId) {
277
598
  return this.#attachments.get(threadId) || null;
278
599
  }
279
600
 
280
- /** @returns {Array<{ threadId: string, taskId: string }>} */
281
601
  listAttachments() {
282
602
  return [...this.#attachments.entries()].map(([threadId, taskId]) => ({ threadId, taskId }));
283
603
  }
284
604
  }
285
605
 
606
+ // ─── Singleton helpers ───────────────────────────────────────────────────
607
+
286
608
  /** @type {ThreadStore|null} */
287
609
  let threadStore = null;
288
610
 
289
611
  /**
290
- * Initialize the thread store. Safe to call multiple times — subsequent calls
612
+ * Initialise the thread store. Safe to call multiple times — subsequent calls
291
613
  * replace the store only if `force` is true (primarily for tests).
292
- * @param {{ force?: boolean }} [opts]
614
+ *
615
+ * Accepts either `initThreadStore()` (legacy, in-memory) or
616
+ * `initThreadStore(yeaftDir, opts)` (persistent). Legacy callers keep working.
617
+ *
618
+ * @param {string|{ force?: boolean }} [yeaftDirOrOpts]
619
+ * @param {{ force?: boolean, readOnly?: boolean }} [opts]
293
620
  * @returns {ThreadStore}
294
621
  */
295
- export function initThreadStore(opts = {}) {
296
- if (!threadStore || opts.force) {
297
- threadStore = new ThreadStore();
622
+ export function initThreadStore(yeaftDirOrOpts, opts = {}) {
623
+ let yeaftDir;
624
+ let mergedOpts;
625
+ if (typeof yeaftDirOrOpts === 'string') {
626
+ yeaftDir = yeaftDirOrOpts;
627
+ mergedOpts = opts || {};
628
+ } else {
629
+ yeaftDir = undefined;
630
+ mergedOpts = yeaftDirOrOpts || {};
631
+ }
632
+ if (!threadStore || mergedOpts.force) {
633
+ threadStore = new ThreadStore(yeaftDir, mergedOpts);
298
634
  }
299
635
  return threadStore;
300
636
  }
@@ -309,5 +645,11 @@ export function getThreadStore() {
309
645
 
310
646
  /** Test-only reset helper. */
311
647
  export function _resetThreadStoreForTests() {
648
+ if (threadStore && typeof threadStore.flush === 'function') {
649
+ try { threadStore.flush(); } catch { /* ignore */ }
650
+ }
312
651
  threadStore = null;
313
652
  }
653
+
654
+ // Exported for tests.
655
+ export { serializeThread as _serializeThread, parseThread as _parseThread };
@@ -16,6 +16,7 @@
16
16
  import { loadSession } from './session.js';
17
17
  import { sendToServer } from '../connection/buffer.js';
18
18
  import ctx from '../context.js';
19
+ import { getThreadStore } from './threads/store.js';
19
20
 
20
21
  /** @type {import('./session.js').Session | null} */
21
22
  let session = null;
@@ -72,6 +73,49 @@ function sendUnifyEvent(event) {
72
73
  });
73
74
  }
74
75
 
76
+ /**
77
+ * task-301 Part 2: push the full thread list snapshot to the web client.
78
+ * Called after any ThreadStore-mutating tool completes and at turn_end so
79
+ * the sidebar V2 always shows a fresh picture. Cheap — ThreadStore keeps
80
+ * cached counters so list() is O(n) over a small n.
81
+ */
82
+ function sendThreadListUpdate() {
83
+ try {
84
+ const store = getThreadStore();
85
+ const threads = store.list().map(t => ({
86
+ id: t.id,
87
+ name: t.name,
88
+ goal: t.goal || '',
89
+ parentThreadId: t.parentThreadId || null,
90
+ status: t.status,
91
+ archived: !!t.archived,
92
+ messageCount: t.messageCount || 0,
93
+ lastMessageAt: t.lastMessageAt || null,
94
+ lastActivityAt: t.lastActivityAt || t.lastMessageAt || t.updatedAt || null,
95
+ unread: t.unread || 0,
96
+ preview: t.preview || '',
97
+ createdAt: t.createdAt,
98
+ updatedAt: t.updatedAt,
99
+ // `running` — the thread whose id equals the store's currentId is
100
+ // considered the active/running track. The UI uses this for the
101
+ // green halo in the Active group.
102
+ running: t.id === store.currentId,
103
+ }));
104
+ sendUnifyEvent({ type: 'thread_list_updated', threads, currentThreadId: store.currentId });
105
+ } catch (err) {
106
+ // Best-effort; sidebar update must never block the main query path.
107
+ console.warn('[Unify] sendThreadListUpdate failed:', err?.message || err);
108
+ }
109
+ }
110
+
111
+ /** Tool names that mutate ThreadStore. After any of these we push an update. */
112
+ const THREAD_MUTATING_TOOLS = new Set([
113
+ 'SpawnThread',
114
+ 'SwitchThread',
115
+ 'ArchiveThread',
116
+ 'AttachThreadToTask',
117
+ ]);
118
+
75
119
  /**
76
120
  * Handle a unify_chat message from the web UI.
77
121
  *
@@ -118,6 +162,9 @@ export async function handleUnifyChat(msg) {
118
162
  mcpServers: session.status.mcpServers,
119
163
  tools: session.status.tools,
120
164
  });
165
+ // task-301 Part 2: initial thread snapshot so sidebar V2 renders
166
+ // the real 'main' thread (and any restored threads) right away.
167
+ sendThreadListUpdate();
121
168
  }
122
169
 
123
170
  // ─── Cancel any in-flight query ──
@@ -214,6 +261,11 @@ export async function handleUnifyChat(msg) {
214
261
  }],
215
262
  threadId: event.threadId,
216
263
  });
264
+ // task-301 Part 2: if this tool mutates ThreadStore, push a
265
+ // fresh snapshot to the sidebar immediately.
266
+ if (THREAD_MUTATING_TOOLS.has(event.name)) {
267
+ sendThreadListUpdate();
268
+ }
217
269
  break;
218
270
 
219
271
  // ── Turn boundaries ──
@@ -476,6 +528,8 @@ export async function handleUnifyLoadHistory(msg) {
476
528
  mcpServers: session.status.mcpServers,
477
529
  tools: session.status.tools,
478
530
  });
531
+ // task-301 Part 2: initial thread snapshot for history-load session.
532
+ sendThreadListUpdate();
479
533
  }
480
534
 
481
535
  const limit = msg.limit || 50;
@@ -548,6 +602,8 @@ export async function resetUnifySession() {
548
602
  mcpServers: session.status.mcpServers,
549
603
  tools: session.status.tools,
550
604
  });
605
+ // task-301 Part 2: re-push thread snapshot after session reset.
606
+ sendThreadListUpdate();
551
607
  } catch (err) {
552
608
  console.error('[Unify] Failed to re-initialize session after reset:', err.message);
553
609
  }