@pellux/goodvibes-tui 1.20.0 → 1.21.0

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +7 -7
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/bundle-command.ts +1 -1
  6. package/src/cli/doctor.ts +7 -1
  7. package/src/cli/management.ts +1 -1
  8. package/src/cli/package-verification.ts +92 -7
  9. package/src/cli/status.ts +16 -0
  10. package/src/config/read-versioned.ts +115 -2
  11. package/src/core/active-model-identity.ts +153 -0
  12. package/src/core/conversation-event-navigation.ts +42 -0
  13. package/src/core/conversation-search-expansion.ts +67 -0
  14. package/src/core/conversation-splash-state.ts +58 -0
  15. package/src/core/conversation-user-receipts.ts +42 -0
  16. package/src/core/conversation.ts +43 -40
  17. package/src/core/model-routing-chip.ts +38 -18
  18. package/src/core/rewind-turn-anchors.ts +155 -3
  19. package/src/core/stream-event-wiring.ts +298 -17
  20. package/src/core/system-message-router.ts +22 -0
  21. package/src/core/transcript-journal.ts +145 -7
  22. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  23. package/src/daemon/handlers/inbox/index.ts +14 -1
  24. package/src/daemon/handlers/sqlite-store.ts +160 -6
  25. package/src/input/command-registry.ts +8 -0
  26. package/src/input/commands/local-runtime.ts +6 -1
  27. package/src/input/commands/schedule-runtime.ts +15 -2
  28. package/src/input/commands/shell-core.ts +87 -38
  29. package/src/input/composer-capture.ts +47 -0
  30. package/src/input/handler-command-route.ts +4 -0
  31. package/src/input/handler-content-actions.ts +9 -0
  32. package/src/input/handler-feed-routes.ts +1 -0
  33. package/src/input/handler-modal-routes.ts +12 -1
  34. package/src/input/handler-picker-routes.ts +43 -5
  35. package/src/input/handler-shortcuts.ts +1 -1
  36. package/src/input/handler-ui-state.ts +8 -3
  37. package/src/input/model-picker-items.ts +17 -2
  38. package/src/input/model-picker.ts +19 -2
  39. package/src/input/search.ts +183 -89
  40. package/src/main.ts +31 -38
  41. package/src/{cli → providers}/provider-classification.ts +12 -0
  42. package/src/providers/reasoning-effort-surface.ts +399 -0
  43. package/src/renderer/compositor.ts +68 -18
  44. package/src/renderer/header-line.ts +146 -0
  45. package/src/renderer/markdown-inline.ts +143 -0
  46. package/src/renderer/markdown-table.ts +459 -0
  47. package/src/renderer/markdown.ts +6 -335
  48. package/src/renderer/model-workspace.ts +48 -5
  49. package/src/renderer/shell-surface.ts +36 -0
  50. package/src/renderer/ui-factory.ts +25 -78
  51. package/src/runtime/bootstrap-command-parts.ts +36 -4
  52. package/src/runtime/bootstrap-core.ts +1 -1
  53. package/src/runtime/durability-housekeeping.ts +183 -0
  54. package/src/runtime/durability-services.ts +68 -11
  55. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  56. package/src/runtime/provider-fallback.ts +16 -2
  57. package/src/runtime/recovery-decisions.ts +295 -0
  58. package/src/runtime/recovery-prompt.ts +66 -6
  59. package/src/runtime/services.ts +9 -4
  60. package/src/runtime/session-liveness-marker.ts +142 -5
  61. package/src/runtime/workstream-draft-store.ts +198 -25
  62. package/src/runtime/workstream-services.ts +17 -2
  63. package/src/shell/ui-openers.ts +1 -1
  64. package/src/version.ts +1 -1
  65. package/src/work-plans/work-plan-store.ts +334 -16
@@ -21,19 +21,99 @@
21
21
  // unreadable or malformed file, or a write failure degrades to "that draft
22
22
  // isn't there" rather than crashing session startup — mirroring the engine
23
23
  // persistence layer's own quarantine-don't-propagate discipline.
24
+ //
25
+ // RECLAIM: `remove(id)` retires a draft the moment it is launched or cancelled,
26
+ // but a draft the user simply walks away from was never reclaimed by anything,
27
+ // so the directory grew forever. `loadAll()` — the recovery point, called once
28
+ // at command-facade construction — now also bounds the directory by BOTH an age
29
+ // TTL and a count cap, and reports what it reclaimed through `lastReclaim` /
30
+ // the `onReclaim` hook. Deletions are `rmSync(..., { force: true })`, so a file
31
+ // another process removed first is not an error: two concurrent `loadAll()`
32
+ // calls converge on the same surviving set.
24
33
  // ---------------------------------------------------------------------------
25
34
 
26
- import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
35
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
27
36
  import { join } from 'node:path';
37
+ import { atomicWriteFileSync } from '@/config/atomic-write.ts';
28
38
  import type { WorkstreamDraft } from './workstream-draft-types.ts';
29
39
 
40
+ /**
41
+ * Age TTL for an abandoned draft. A proposal that has sat un-launched and
42
+ * un-cancelled this long is not a plan the user is still reviewing; it is
43
+ * litter. Measured from the draft's own `createdAt`.
44
+ */
45
+ export const WORKSTREAM_DRAFT_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
46
+
47
+ /**
48
+ * Count cap on retained drafts: the newest this many survive, older ones are
49
+ * reclaimed oldest-first. Bounds the directory even if every draft is recent.
50
+ */
51
+ export const WORKSTREAM_DRAFT_CAP = 50;
52
+
53
+ /**
54
+ * Minimum gap between reaps triggered by `save()`. `loadAll()` is the recovery
55
+ * point but runs once per session, so a session that stays open for days would
56
+ * otherwise never reap again. Every `save()` (draft created, reshaped, or
57
+ * approved) re-checks the bounds, throttled to this interval so the directory
58
+ * is not re-scanned on every keystroke-driven save.
59
+ */
60
+ export const WORKSTREAM_DRAFT_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
61
+
62
+ /** What one `loadAll()` reclaimed. Counts only — never draft text. */
63
+ export interface WorkstreamDraftReclaim {
64
+ /** Unix ms of the sweep. */
65
+ readonly at: number;
66
+ /** Valid drafts removed because they aged past the TTL. */
67
+ readonly expired: number;
68
+ /** Valid drafts removed because they fell outside the count cap. */
69
+ readonly overCap: number;
70
+ /** Torn/malformed snapshots removed once they were older than the TTL. */
71
+ readonly unreadable: number;
72
+ }
73
+
74
+ export interface WorkstreamDraftStoreOptions {
75
+ /**
76
+ * Called after a `loadAll()` that reclaimed at least one file, so the count
77
+ * can be surfaced to the operator. Receives counts only. Never throws out of
78
+ * `loadAll()` — a failing hook is swallowed like every other side effect here.
79
+ */
80
+ readonly onReclaim?: (summary: WorkstreamDraftReclaim) => void;
81
+ /** Clock seam (tests). Defaults to Date.now. */
82
+ readonly now?: () => number;
83
+ /** Override the age TTL (tests / embedders). Defaults to WORKSTREAM_DRAFT_TTL_MS. */
84
+ readonly ttlMs?: number;
85
+ /** Override the count cap (tests / embedders). Defaults to WORKSTREAM_DRAFT_CAP. */
86
+ readonly cap?: number;
87
+ /**
88
+ * Throttle for the reap that `save()` triggers. 0 or less means every save
89
+ * reaps (tests); defaults to WORKSTREAM_DRAFT_SWEEP_INTERVAL_MS.
90
+ */
91
+ readonly sweepIntervalMs?: number;
92
+ }
93
+
30
94
  export interface WorkstreamDraftStore {
31
- /** Every valid draft on disk, oldest first. A directory that doesn't exist yet ⇒ []. Malformed files are skipped, never fatal. */
95
+ /**
96
+ * Every valid draft on disk, oldest first, after reaping abandoned ones. A
97
+ * directory that doesn't exist yet ⇒ []. Malformed files are skipped, never
98
+ * fatal, and are only deleted once older than the TTL.
99
+ */
32
100
  loadAll(): WorkstreamDraft[];
33
- /** Write (or overwrite) one draft's snapshot. Silently no-ops on any I/O failure — a lost journal write must never break the in-memory flow. */
101
+ /** Write (or overwrite) one draft's snapshot atomically. Silently no-ops on any I/O failure — a lost journal write must never break the in-memory flow. */
34
102
  save(draft: WorkstreamDraft): void;
35
103
  /** Delete one draft's snapshot (on launch or cancel). Missing file ⇒ no-op. */
36
104
  remove(id: string): void;
105
+ /** Summary of the most recent `loadAll()` that reclaimed something; null otherwise. */
106
+ readonly lastReclaim: WorkstreamDraftReclaim | null;
107
+ }
108
+
109
+ /** Human-readable, content-free disclosure line for a reclaim summary. */
110
+ export function formatWorkstreamDraftReclaim(summary: WorkstreamDraftReclaim): string {
111
+ const parts: string[] = [];
112
+ if (summary.expired > 0) parts.push(`${summary.expired} abandoned`);
113
+ if (summary.overCap > 0) parts.push(`${summary.overCap} over the retention cap`);
114
+ if (summary.unreadable > 0) parts.push(`${summary.unreadable} unreadable`);
115
+ if (parts.length === 0) return 'Workstream drafts: nothing reclaimed.';
116
+ return `Workstream drafts: reclaimed ${parts.join(', ')} draft file(s).`;
37
117
  }
38
118
 
39
119
  /** Draft ids are `wsd_<hex>`; this refuses anything else so a crafted id can never escape the drafts directory. */
@@ -54,48 +134,141 @@ function isDraftShape(value: unknown): value is WorkstreamDraft {
54
134
  );
55
135
  }
56
136
 
57
- export function createWorkstreamDraftStore(projectRoot: string): WorkstreamDraftStore {
137
+ export function createWorkstreamDraftStore(
138
+ projectRoot: string,
139
+ options: WorkstreamDraftStoreOptions = {},
140
+ ): WorkstreamDraftStore {
58
141
  const dir = join(projectRoot, '.goodvibes', 'orchestration', 'drafts');
59
142
  const fileFor = (id: string): string => join(dir, `${id}.json`);
143
+ const clock = options.now ?? Date.now;
144
+ const ttlMs = options.ttlMs ?? WORKSTREAM_DRAFT_TTL_MS;
145
+ const cap = options.cap ?? WORKSTREAM_DRAFT_CAP;
146
+ const sweepIntervalMs = options.sweepIntervalMs ?? WORKSTREAM_DRAFT_SWEEP_INTERVAL_MS;
147
+ let lastReclaim: WorkstreamDraftReclaim | null = null;
148
+ /** Unix ms of this instance's last reap; 0 means "never", so the first save sweeps. */
149
+ let lastSweepAt = 0;
60
150
 
61
- return {
62
- loadAll(): WorkstreamDraft[] {
63
- let entries: string[];
151
+ /** Delete one path, treating "already gone" as success (concurrent sweeps). */
152
+ const discard = (path: string): void => {
153
+ try {
154
+ rmSync(path, { force: true });
155
+ } catch {
156
+ // Unremovable (permissions, or a racing process holding it) — the next
157
+ // loadAll() will try again; never fatal.
158
+ }
159
+ };
160
+
161
+ /** Age of a file by mtime, used only for snapshots whose contents we cannot trust. */
162
+ const modifiedBefore = (path: string, cutoff: number): boolean => {
163
+ try {
164
+ return statSync(path).mtimeMs < cutoff;
165
+ } catch {
166
+ return false;
167
+ }
168
+ };
169
+
170
+ /**
171
+ * Read every snapshot, reject the ones whose CONTENT is not a draft, apply
172
+ * both bounds, and record/announce what was reclaimed. Returns the surviving
173
+ * drafts, oldest first. Never throws.
174
+ *
175
+ * Idempotent: a second run over the same directory finds nothing left to
176
+ * remove, which is also what makes two processes running it at once safe —
177
+ * they converge on the same surviving set and unlink races count as done.
178
+ */
179
+ const reap = (): WorkstreamDraft[] => {
180
+ let entries: string[];
181
+ try {
182
+ entries = readdirSync(dir);
183
+ } catch {
184
+ lastReclaim = null;
185
+ return []; // directory not created yet, or unreadable — nothing to resume
186
+ }
187
+ const now = clock();
188
+ lastSweepAt = now;
189
+ const cutoff = now - ttlMs;
190
+ const drafts: WorkstreamDraft[] = [];
191
+ let unreadable = 0;
192
+ for (const entry of entries) {
193
+ if (!entry.endsWith('.json')) continue;
194
+ const path = join(dir, entry);
195
+ let parsed: unknown;
64
196
  try {
65
- entries = readdirSync(dir);
197
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
66
198
  } catch {
67
- return []; // directory not created yet, or unreadable — nothing to resume
199
+ parsed = undefined;
68
200
  }
69
- const drafts: WorkstreamDraft[] = [];
70
- for (const entry of entries) {
71
- if (!entry.endsWith('.json')) continue;
72
- try {
73
- const parsed: unknown = JSON.parse(readFileSync(join(dir, entry), 'utf8'));
74
- if (isDraftShape(parsed) && isSafeId(parsed.id)) drafts.push(parsed);
75
- } catch {
76
- // A malformed or half-written snapshot is skipped, never fatal.
77
- }
201
+ // Validation is by CONTENT: a torn or half-written snapshot never
202
+ // becomes a draft. It is only DELETED once it is older than the TTL, so
203
+ // a file another process is writing right now is left alone.
204
+ if (parsed !== undefined && isDraftShape(parsed) && isSafeId(parsed.id)) {
205
+ drafts.push(parsed);
206
+ continue;
78
207
  }
79
- return drafts.sort((a, b) => a.createdAt - b.createdAt);
208
+ if (modifiedBefore(path, cutoff)) {
209
+ discard(path);
210
+ unreadable += 1;
211
+ }
212
+ }
213
+ drafts.sort((a, b) => a.createdAt - b.createdAt);
214
+
215
+ // Bound 1 — age TTL: an abandoned proposal is reclaimed.
216
+ const live: WorkstreamDraft[] = [];
217
+ let expired = 0;
218
+ for (const draft of drafts) {
219
+ if (now - draft.createdAt > ttlMs) {
220
+ discard(fileFor(draft.id));
221
+ expired += 1;
222
+ continue;
223
+ }
224
+ live.push(draft);
225
+ }
226
+ // Bound 2 — count cap: keep the newest `cap`, reclaim the oldest overflow.
227
+ const overCapCount = Math.max(0, live.length - cap);
228
+ for (const draft of live.slice(0, overCapCount)) discard(fileFor(draft.id));
229
+ const kept = live.slice(overCapCount);
230
+
231
+ if (expired + overCapCount + unreadable > 0) {
232
+ lastReclaim = { at: now, expired, overCap: overCapCount, unreadable };
233
+ try {
234
+ options.onReclaim?.(lastReclaim);
235
+ } catch {
236
+ // Disclosure must never take startup down.
237
+ }
238
+ } else {
239
+ lastReclaim = null;
240
+ }
241
+ return kept;
242
+ };
243
+
244
+ return {
245
+ get lastReclaim(): WorkstreamDraftReclaim | null {
246
+ return lastReclaim;
247
+ },
248
+ loadAll(): WorkstreamDraft[] {
249
+ return reap();
80
250
  },
81
251
  save(draft: WorkstreamDraft): void {
82
252
  if (!isSafeId(draft.id)) return;
83
253
  try {
84
254
  mkdirSync(dir, { recursive: true });
85
- writeFileSync(fileFor(draft.id), JSON.stringify(draft), 'utf8');
255
+ // Atomic (temp + fsync + rename): a crash mid-save can no longer leave a
256
+ // truncated snapshot behind for loadAll() to reject.
257
+ atomicWriteFileSync(fileFor(draft.id), JSON.stringify(draft));
86
258
  } catch {
87
259
  // A failed journal write leaves the in-memory draft intact and usable;
88
260
  // only its restart-durability is lost, which is strictly better than
89
261
  // failing the edit the user just made.
90
262
  }
263
+ // Keep reaping DURING the session, not only at the loadAll() recovery
264
+ // point: loadAll runs once at construction, so a long-lived session would
265
+ // otherwise never bound the directory again. Throttled, and always after
266
+ // this draft is on disk so the newest file is never the one reclaimed.
267
+ if (clock() - lastSweepAt >= sweepIntervalMs) reap();
91
268
  },
92
269
  remove(id: string): void {
93
270
  if (!isSafeId(id)) return;
94
- try {
95
- rmSync(fileFor(id), { force: true });
96
- } catch {
97
- // Already gone, or unremovable — nothing more to do.
98
- }
271
+ discard(fileFor(id));
99
272
  },
100
273
  };
101
274
  }
@@ -56,7 +56,8 @@ import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
56
56
  import type { RuntimeEventBus } from '@/runtime/index.ts';
57
57
  import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
58
58
  import { editItemBrief, moveItemInSpec, removeItemFromSpec } from './workstream-draft-edits.ts';
59
- import { createWorkstreamDraftStore } from './workstream-draft-store.ts';
59
+ import { createWorkstreamDraftStore, formatWorkstreamDraftReclaim } from './workstream-draft-store.ts';
60
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
60
61
 
61
62
  export interface WorkstreamServicesDeps {
62
63
  readonly agentManager: Pick<AgentManager, 'spawn' | 'getStatus' | 'cancel' | 'registerCancellationSignal' | 'releaseCancellationSignal'>;
@@ -184,7 +185,21 @@ function createWorkstreamCommandService(
184
185
  // proposal at construction so a create/reshape/approve done before a restart
185
186
  // is still here to launch afterward — the plan-review gate survives restart,
186
187
  // exactly as the live-workstream snapshots do via resumeAllFromDisk().
187
- const store = createWorkstreamDraftStore(projectRoot);
188
+ // Reclaiming an abandoned draft deletes a plan the user once wrote, so it is
189
+ // never done silently: the store reaps at load and on a throttled save, and
190
+ // every reap that actually removed something reports a content-free count
191
+ // here. Without this hook the store recorded the reclaim on itself and
192
+ // nothing ever read it, which is deletion the user has no way to notice.
193
+ const store = createWorkstreamDraftStore(projectRoot, {
194
+ onReclaim: (summary) => {
195
+ logger.info(formatWorkstreamDraftReclaim(summary), {
196
+ projectRoot,
197
+ expired: summary.expired,
198
+ overCap: summary.overCap,
199
+ unreadable: summary.unreadable,
200
+ });
201
+ },
202
+ });
188
203
  for (const persisted of store.loadAll()) drafts.set(persisted.id, persisted);
189
204
 
190
205
  /**
@@ -373,7 +373,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
373
373
  // 'search' case) — the transcript search overlay isn't tracked on the
374
374
  // modal stack, so this doesn't call modalOpened either.
375
375
  commandContext.openTranscriptSearch = () => {
376
- if (input.searchManager.active) input.searchManager.close();
376
+ if (input.searchManager.active) input.searchManager.close(input.conversationManager);
377
377
  else input.searchManager.open();
378
378
  render();
379
379
  };
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.20.0';
9
+ let _version = '1.21.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  // Only trust a version read from OUR OWN package.json. A Bun single-file