@pellux/goodvibes-tui 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +20 -12
  3. package/docs/foundation-artifacts/operator-contract.json +304 -230
  4. package/package.json +2 -2
  5. package/src/cli/management.ts +80 -10
  6. package/src/core/long-task-notifier.ts +145 -0
  7. package/src/core/session-recovery.ts +147 -0
  8. package/src/core/stream-event-wiring.ts +77 -3
  9. package/src/core/transcript-journal.ts +339 -0
  10. package/src/core/turn-event-wiring.ts +67 -4
  11. package/src/input/commands/control-room-runtime.ts +0 -2
  12. package/src/input/commands/diff-runtime.ts +1 -1
  13. package/src/input/commands/eval.ts +1 -1
  14. package/src/input/commands/health-runtime.ts +23 -4
  15. package/src/input/commands/knowledge.ts +1 -1
  16. package/src/input/commands/local-runtime.ts +1 -2
  17. package/src/input/commands/memory-product-runtime.ts +2 -2
  18. package/src/input/commands/memory.ts +1 -1
  19. package/src/input/commands/onboarding-runtime.ts +0 -1
  20. package/src/input/commands/policy.ts +1 -1
  21. package/src/input/commands/profile-sync-runtime.ts +4 -3
  22. package/src/input/commands/provider.ts +1 -1
  23. package/src/input/commands/qrcode-runtime.ts +0 -1
  24. package/src/input/commands/session-content.ts +2 -2
  25. package/src/input/commands/session-workflow.ts +32 -2
  26. package/src/input/commands/session.ts +1 -1
  27. package/src/input/commands/settings-sync-runtime.ts +9 -9
  28. package/src/input/commands/shell-core.ts +2 -2
  29. package/src/input/commands/work-plan-runtime.ts +8 -8
  30. package/src/input/feed-context-factory.ts +6 -0
  31. package/src/input/handler-feed-routes.ts +19 -1
  32. package/src/input/handler-feed.ts +11 -0
  33. package/src/input/handler-prompt-buffer.ts +28 -0
  34. package/src/input/handler-shortcuts.ts +88 -2
  35. package/src/input/handler-ui-state.ts +2 -2
  36. package/src/input/handler.ts +39 -3
  37. package/src/input/keybindings.ts +33 -3
  38. package/src/input/kill-ring.ts +134 -0
  39. package/src/input/model-picker.ts +18 -1
  40. package/src/input/search.ts +18 -6
  41. package/src/input/settings-modal-activation.ts +134 -0
  42. package/src/input/settings-modal-adjustment.ts +124 -0
  43. package/src/input/settings-modal-data.ts +53 -0
  44. package/src/input/settings-modal.ts +48 -145
  45. package/src/main.ts +33 -33
  46. package/src/panels/base-panel.ts +2 -1
  47. package/src/panels/provider-health-domains.ts +3 -3
  48. package/src/panels/provider-health-panel.ts +13 -9
  49. package/src/panels/provider-health-tracker.ts +7 -4
  50. package/src/panels/settings-sync-panel.ts +3 -3
  51. package/src/panels/work-plan-panel.ts +2 -2
  52. package/src/renderer/diff-view.ts +2 -2
  53. package/src/renderer/help-overlay.ts +1 -0
  54. package/src/renderer/model-picker-overlay.ts +23 -11
  55. package/src/renderer/progress.ts +3 -3
  56. package/src/renderer/search-overlay.ts +8 -5
  57. package/src/renderer/settings-modal.ts +1 -1
  58. package/src/renderer/ui-factory.ts +11 -0
  59. package/src/runtime/bootstrap-hook-bridge.ts +18 -0
  60. package/src/runtime/bootstrap-shell.ts +1 -0
  61. package/src/shell/blocking-input.ts +32 -0
  62. package/src/shell/recovery-input-helpers.ts +71 -0
  63. package/src/utils/terminal-width.ts +10 -3
  64. package/src/version.ts +1 -1
@@ -0,0 +1,339 @@
1
+ /**
2
+ * transcript-journal.ts — WAL-style append-only transcript journal.
3
+ *
4
+ * Purpose
5
+ * ───────
6
+ * Between full snapshots (written by persistConversation / writeRecoveryFile),
7
+ * a SIGKILL loses every conversation turn since the last snapshot. This module
8
+ * provides an append-only journal that records each durable conversation event
9
+ * (user message submitted, assistant turn finalised, tool results appended,
10
+ * compaction performed) so that a kill at any moment loses at most the
11
+ * in-flight append — never a full turn.
12
+ *
13
+ * File format (NDJSON)
14
+ * ────────────────────
15
+ * Line 0 — header: { version: 1, sessionId: "...", createdAt: <epochMs> }
16
+ * Line 1+ — records: { type, seq, ts, messages: ConversationMessageSnapshot[] }
17
+ *
18
+ * The header carries the schemaVersion so that a reader from a future process
19
+ * can gate on it (readVersioned convention: unknown version → quarantine).
20
+ *
21
+ * Durability / performance tradeoff
22
+ * ──────────────────────────────────
23
+ * appendRecord() performs one appendFileSync + one fsyncSync per call.
24
+ * This means one fsync per durable conversation event (user message,
25
+ * assistant turn, tool result batch, compaction). It does NOT fsync per
26
+ * streaming token — the streaming path never calls appendRecord().
27
+ *
28
+ * At typical usage (a few events per user turn), this is 2–6 fsyncs/min,
29
+ * well within the durability/throughput envelope of any modern filesystem.
30
+ * The tradeoff is explicit: we accept per-event write amplification in
31
+ * exchange for at-most-one-record loss on SIGKILL.
32
+ *
33
+ * Recovery semantics
34
+ * ──────────────────
35
+ * 1. Read the header line. Gate on version — quarantine if unrecognised.
36
+ * 2. Read subsequent lines until EOF. Stop at the first line that is not
37
+ * valid JSON or lacks the expected shape. Quarantine the remainder of
38
+ * the file from that point onward (rename to .unrecognized). Never crash.
39
+ * 3. Return only records whose `ts` is strictly greater than the provided
40
+ * `snapshotTimestamp` (i.e. events that occurred after the last snapshot).
41
+ * 4. Caller replays the returned records in `seq` order atop the snapshot
42
+ * to reconstruct the conversation, then writes a fresh snapshot and
43
+ * calls `journal.rotate()` to truncate the journal.
44
+ *
45
+ * Rotation
46
+ * ────────
47
+ * After a fresh snapshot is written, call journal.rotate() which deletes the
48
+ * journal file. The next append will recreate it with a fresh header.
49
+ *
50
+ * Journal path convention
51
+ * ───────────────────────
52
+ * <homeDirectory>/.goodvibes/tui/transcript-<sessionId>.journal
53
+ * This mirrors the recovery-file location (homeDirectory-scoped, not
54
+ * workingDir-scoped) so all per-session durability artefacts live together.
55
+ */
56
+
57
+ import {
58
+ appendFileSync,
59
+ closeSync,
60
+ existsSync,
61
+ fsyncSync,
62
+ mkdirSync,
63
+ openSync,
64
+ readFileSync,
65
+ renameSync,
66
+ unlinkSync,
67
+ } from 'node:fs';
68
+ import { dirname, join } from 'node:path';
69
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
70
+
71
+ // ─── Constants ──────────────────────────────────────────────────────────────
72
+
73
+ export const JOURNAL_SCHEMA_VERSION = 1;
74
+
75
+ // ─── Types ──────────────────────────────────────────────────────────────────
76
+
77
+ export interface JournalHeader {
78
+ readonly version: typeof JOURNAL_SCHEMA_VERSION;
79
+ readonly sessionId: string;
80
+ readonly createdAt: number;
81
+ }
82
+
83
+ export type JournalEventType =
84
+ | 'user_message'
85
+ | 'assistant_turn'
86
+ | 'tool_results'
87
+ | 'compaction';
88
+
89
+ export interface JournalRecord {
90
+ /** Discriminator for the kind of durable event. */
91
+ readonly type: JournalEventType;
92
+ /** Monotonically increasing sequence number (0-based, per journal file). */
93
+ readonly seq: number;
94
+ /** Wall-clock timestamp (Date.now()) when the record was appended. */
95
+ readonly ts: number;
96
+ /** Full conversation message snapshot at the time of the event. */
97
+ readonly messages: ConversationMessageSnapshot[];
98
+ }
99
+
100
+ export interface ReplayResult {
101
+ /** Records whose ts is strictly after snapshotTimestamp, in seq order. */
102
+ readonly records: JournalRecord[];
103
+ /**
104
+ * True if the journal tail was corrupt (a partial write from a kill).
105
+ * The corrupt tail has been quarantined; replay stopped at the last
106
+ * good record.
107
+ */
108
+ readonly hadCorruptTail: boolean;
109
+ }
110
+
111
+ // ─── Public API ─────────────────────────────────────────────────────────────
112
+
113
+ export interface TranscriptJournal {
114
+ /**
115
+ * Append one durable event record and fsync it to disk.
116
+ *
117
+ * Best-effort: if the write fails (e.g. disk full), the error is swallowed
118
+ * — the journal is durability-enhancing, never a hard requirement.
119
+ */
120
+ appendRecord(type: JournalEventType, messages: ConversationMessageSnapshot[]): void;
121
+
122
+ /**
123
+ * Delete the journal file (called after a fresh snapshot is written).
124
+ * The next appendRecord() will recreate the file with a fresh header.
125
+ * Best-effort — silently swallows errors.
126
+ */
127
+ rotate(): void;
128
+
129
+ /** Absolute path to the journal file. */
130
+ readonly path: string;
131
+ }
132
+
133
+ // ─── Factory ────────────────────────────────────────────────────────────────
134
+
135
+ /**
136
+ * Create a TranscriptJournal for the given session.
137
+ *
138
+ * The journal file is created lazily on the first appendRecord() call.
139
+ * Calling openTranscriptJournal() does not perform any I/O.
140
+ */
141
+ export function openTranscriptJournal(
142
+ journalPath: string,
143
+ sessionId: string,
144
+ ): TranscriptJournal {
145
+ return new TranscriptJournalImpl(journalPath, sessionId);
146
+ }
147
+
148
+ /**
149
+ * Build the canonical journal path for a session.
150
+ *
151
+ * @param homeDirectory The goodvibes home directory (e.g. ~/.goodvibes).
152
+ * @param sessionId The session identifier.
153
+ */
154
+ export function journalPathFor(homeDirectory: string, sessionId: string): string {
155
+ return join(homeDirectory, '.goodvibes', 'tui', `transcript-${sessionId}.journal`);
156
+ }
157
+
158
+ /**
159
+ * Replay journal records that post-date `snapshotTimestamp`.
160
+ *
161
+ * Returns an empty result if the journal file does not exist.
162
+ * Corrupt tail lines (partial write from a kill) are quarantined; replay
163
+ * stops at the first unparseable line.
164
+ *
165
+ * @param journalPath Absolute path to the journal file.
166
+ * @param snapshotTimestamp The `writtenAt` / `timestamp` of the last known
167
+ * good snapshot. Only records with ts > this value
168
+ * are returned.
169
+ */
170
+ export function replayJournal(
171
+ journalPath: string,
172
+ snapshotTimestamp: number,
173
+ ): ReplayResult {
174
+ if (!existsSync(journalPath)) {
175
+ return { records: [], hadCorruptTail: false };
176
+ }
177
+
178
+ let raw: string;
179
+ try {
180
+ raw = readFileSync(journalPath, 'utf-8');
181
+ } catch {
182
+ return { records: [], hadCorruptTail: false };
183
+ }
184
+
185
+ const lines = raw.split('\n').filter((l) => l.trim().length > 0);
186
+ if (lines.length === 0) {
187
+ return { records: [], hadCorruptTail: false };
188
+ }
189
+
190
+ // ── Validate header ─────────────────────────────────────────────────────
191
+ let header: unknown;
192
+ try {
193
+ header = JSON.parse(lines[0]);
194
+ } catch {
195
+ quarantineJournal(journalPath);
196
+ return { records: [], hadCorruptTail: true };
197
+ }
198
+
199
+ if (
200
+ !isPlainObject(header) ||
201
+ typeof header['version'] !== 'number' ||
202
+ header['version'] !== JOURNAL_SCHEMA_VERSION
203
+ ) {
204
+ quarantineJournal(journalPath);
205
+ return { records: [], hadCorruptTail: true };
206
+ }
207
+
208
+ // ── Read records ────────────────────────────────────────────────────────
209
+ const records: JournalRecord[] = [];
210
+ let firstBadLine = -1;
211
+
212
+ for (let i = 1; i < lines.length; i++) {
213
+ let parsed: unknown;
214
+ try {
215
+ parsed = JSON.parse(lines[i]);
216
+ } catch {
217
+ firstBadLine = i;
218
+ break;
219
+ }
220
+
221
+ if (!isValidRecord(parsed)) {
222
+ firstBadLine = i;
223
+ break;
224
+ }
225
+
226
+ if (parsed.ts > snapshotTimestamp) {
227
+ records.push(parsed);
228
+ }
229
+ }
230
+
231
+ // ── Quarantine corrupt tail ──────────────────────────────────────────────
232
+ let hadCorruptTail = false;
233
+ if (firstBadLine !== -1) {
234
+ hadCorruptTail = true;
235
+ // Quarantine the remainder: rename the file. Caller will rotate after
236
+ // replay anyway, but we quarantine now so the original file is not
237
+ // accidentally replayed again if the process is killed during recovery.
238
+ quarantineJournal(journalPath);
239
+ }
240
+
241
+ // Sort by seq to guarantee ordering in case lines were reordered (they
242
+ // should not be, but be defensive).
243
+ records.sort((a, b) => a.seq - b.seq);
244
+
245
+ return { records, hadCorruptTail };
246
+ }
247
+
248
+ // ─── Implementation ─────────────────────────────────────────────────────────
249
+
250
+ class TranscriptJournalImpl implements TranscriptJournal {
251
+ readonly path: string;
252
+ private readonly _sessionId: string;
253
+ private _seq = 0;
254
+ private _initialised = false;
255
+
256
+ constructor(journalPath: string, sessionId: string) {
257
+ this.path = journalPath;
258
+ this._sessionId = sessionId;
259
+ }
260
+
261
+ appendRecord(type: JournalEventType, messages: ConversationMessageSnapshot[]): void {
262
+ try {
263
+ this._ensureInitialised();
264
+ const record: JournalRecord = {
265
+ type,
266
+ seq: this._seq++,
267
+ ts: Date.now(),
268
+ messages,
269
+ };
270
+ const line = JSON.stringify(record) + '\n';
271
+ appendFileSync(this.path, line, { mode: 0o600 });
272
+ // fsync to flush the append to durable storage before returning.
273
+ const fd = openSync(this.path, 'r+');
274
+ try {
275
+ fsyncSync(fd);
276
+ } finally {
277
+ closeSync(fd);
278
+ }
279
+ } catch {
280
+ // Best-effort — never crash the TUI over a journal failure.
281
+ }
282
+ }
283
+
284
+ rotate(): void {
285
+ try {
286
+ if (existsSync(this.path)) {
287
+ unlinkSync(this.path);
288
+ }
289
+ this._initialised = false;
290
+ this._seq = 0;
291
+ } catch {
292
+ // Best-effort.
293
+ }
294
+ }
295
+
296
+ private _ensureInitialised(): void {
297
+ if (this._initialised && existsSync(this.path)) return;
298
+
299
+ mkdirSync(dirname(this.path), { recursive: true });
300
+ const header: JournalHeader = {
301
+ version: JOURNAL_SCHEMA_VERSION,
302
+ sessionId: this._sessionId,
303
+ createdAt: Date.now(),
304
+ };
305
+ // Append the header as the first line. If the file already exists (e.g.
306
+ // process restarted mid-session), we start appending records after
307
+ // whatever is already there — the replay function handles seq ordering.
308
+ // However, to keep things clean, if the file doesn't exist we write fresh.
309
+ if (!existsSync(this.path)) {
310
+ appendFileSync(this.path, JSON.stringify(header) + '\n', { mode: 0o600 });
311
+ }
312
+ this._initialised = true;
313
+ }
314
+ }
315
+
316
+ // ─── Internal helpers ────────────────────────────────────────────────────────
317
+
318
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
319
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
320
+ }
321
+
322
+ function isValidRecord(value: unknown): value is JournalRecord {
323
+ if (!isPlainObject(value)) return false;
324
+ const v = value as Record<string, unknown>;
325
+ return (
326
+ typeof v['type'] === 'string' &&
327
+ typeof v['seq'] === 'number' &&
328
+ typeof v['ts'] === 'number' &&
329
+ Array.isArray(v['messages'])
330
+ );
331
+ }
332
+
333
+ function quarantineJournal(journalPath: string): void {
334
+ try {
335
+ renameSync(journalPath, `${journalPath}.unrecognized`);
336
+ } catch {
337
+ // Best-effort — if rename fails, proceed silently.
338
+ }
339
+ }
@@ -5,6 +5,9 @@ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
5
5
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
6
  import type { HookDispatcher, HookPhase, HookCategory, HookEventPath } from '@pellux/goodvibes-sdk/platform/hooks';
7
7
  import type { ConversationManager } from './conversation.ts';
8
+ import { journalPathFor, openTranscriptJournal, type TranscriptJournal } from './transcript-journal.ts';
9
+ import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
10
+ import { maybeNotifyLongTask, readNotifyAfterSeconds, type LongTaskStatus } from './long-task-notifier.ts';
8
11
 
9
12
  /** Infer the options param of persistConversation to pick up SessionManager correctly. */
10
13
  type PersistOptions = NonNullable<Parameters<typeof persistConversation>[5]>;
@@ -47,6 +50,19 @@ export interface WireTurnEventHandlersOptions {
47
50
  readonly lastGitInfoRef: { value: unknown };
48
51
  readonly buildSessionContinuityHints: () => Record<string, unknown>;
49
52
  readonly render: () => void;
53
+ /**
54
+ * Outbound webhook notifier. When provided and URLs are configured,
55
+ * long-task push notifications are delivered to configured ntfy/webhook
56
+ * endpoints after the configured threshold. Optional — silently skipped
57
+ * when absent.
58
+ */
59
+ readonly webhookNotifier?: WebhookNotifier | null;
60
+ /**
61
+ * Minimal test seam: injectable clock for controlling Date.now() in tests.
62
+ * Defaults to the real Date.now when absent.
63
+ * @internal — tests only
64
+ */
65
+ readonly _clock?: () => number;
50
66
  }
51
67
 
52
68
  export interface WireTurnEventHandlersResult {
@@ -54,6 +70,8 @@ export interface WireTurnEventHandlersResult {
54
70
  readonly refreshGit: () => void;
55
71
  /** Unsubscribe functions to push into the parent unsubs array. */
56
72
  readonly unsubs: ReadonlyArray<() => void>;
73
+ /** The per-session transcript journal; call appendRecord() for user-submitted events. */
74
+ readonly transcriptJournal: TranscriptJournal;
57
75
  }
58
76
 
59
77
  /**
@@ -74,16 +92,51 @@ export function wireTurnEventHandlers(
74
92
  events, conversation, runtime, orchestrator, configManager,
75
93
  providerRegistry, systemMessageRouter, hookDispatcher,
76
94
  workingDir, homeDirectory, sessionManager, gitStatusProvider,
77
- lastGitInfoRef, buildSessionContinuityHints, render,
95
+ lastGitInfoRef, buildSessionContinuityHints, render, webhookNotifier,
96
+ _clock = Date.now,
78
97
  } = options;
79
98
 
80
99
  const unsubs: Array<() => void> = [];
81
100
 
101
+ // Create the per-session transcript journal. Path mirrors recovery-file
102
+ // convention (homeDirectory-scoped). Created lazily on first append.
103
+ const transcriptJournal: TranscriptJournal = openTranscriptJournal(
104
+ journalPathFor(homeDirectory, runtime.sessionId),
105
+ runtime.sessionId,
106
+ );
107
+
108
+ // Track turn start time for long-task notification threshold.
109
+ let turnStartTime: number | null = null;
110
+
82
111
  const refreshGit = (): void => {
83
112
  gitStatusProvider.refresh().then((info) => { lastGitInfoRef.value = info; render(); }).catch(() => { /* non-fatal */ });
84
113
  };
85
114
 
86
- unsubs.push(events.turns.on('TURN_COMPLETED', () => {
115
+ // Journal user message immediately on TURN_SUBMITTED so a SIGKILL during
116
+ // the subsequent stream loses at most the in-flight token chunk.
117
+ unsubs.push(events.turns.on('TURN_SUBMITTED', () => {
118
+ turnStartTime = _clock();
119
+ try {
120
+ const snap = conversation.toJSON() as { messages: Array<import('./conversation.ts').ConversationMessageSnapshot> };
121
+ transcriptJournal.appendRecord('user_message', snap.messages);
122
+ } catch { /* best-effort */ }
123
+ }));
124
+
125
+ unsubs.push(events.turns.on('TURN_COMPLETED', (evt) => {
126
+ // Long-task push notification: fires when the turn exceeded the threshold.
127
+ const turnElapsedMs = turnStartTime !== null ? _clock() - turnStartTime : 0;
128
+ turnStartTime = null;
129
+ const notifyThreshold = readNotifyAfterSeconds((k) => configManager.get(k as Parameters<typeof configManager.get>[0]));
130
+ // stopReason 'empty_response' signals a non-successful completion.
131
+ const taskStatus: LongTaskStatus = evt.stopReason === 'completed' ? 'ok' : 'fail';
132
+ maybeNotifyLongTask({
133
+ elapsedMs: turnElapsedMs,
134
+ status: taskStatus,
135
+ kind: 'turn',
136
+ sessionId: runtime.sessionId,
137
+ thresholdSeconds: notifyThreshold,
138
+ webhookNotifier: webhookNotifier ?? null,
139
+ });
87
140
  // Auto-save after every LLM turn so kills don't lose the session
88
141
  try {
89
142
  const snapshot = conversation.toJSON() as { messages: Array<import('./conversation.ts').ConversationMessageSnapshot>; timestamp?: number };
@@ -97,7 +150,17 @@ export function wireTurnEventHandlers(
97
150
  { workingDirectory: workingDir, homeDirectory, sessionManager },
98
151
  );
99
152
  hookDispatcher.fire({ path: 'Lifecycle:session:save' as HookEventPath, phase: 'Lifecycle' as HookPhase, category: 'session' as HookCategory, specific: 'save', sessionId: runtime.sessionId, timestamp: Date.now(), payload: { sessionId: runtime.sessionId } }).catch((err: unknown) => logger.debug('hook fire error', { error: summarizeError(err) }));
100
- } catch (e) { logger.debug('auto-save on turn:complete failed', { error: summarizeError(e) }); }
153
+ // Snapshot succeeded rotate the journal (gap-filler no longer needed).
154
+ transcriptJournal.rotate();
155
+ } catch (e) {
156
+ // Snapshot failed — append the turn to the journal so recovery can
157
+ // reconstruct it. Best-effort; never crash the TUI.
158
+ try {
159
+ const snap = conversation.toJSON() as { messages: Array<import('./conversation.ts').ConversationMessageSnapshot> };
160
+ transcriptJournal.appendRecord('assistant_turn', snap.messages);
161
+ } catch { /* best-effort */ }
162
+ logger.debug('auto-save on turn:complete failed', { error: summarizeError(e) });
163
+ }
101
164
  // Auto-compact: check context usage and compact if threshold exceeded
102
165
  const currentModelForCompact = providerRegistry.getCurrentModel();
103
166
  maybeAutoCompact({
@@ -120,5 +183,5 @@ export function wireTurnEventHandlers(
120
183
  refreshGit();
121
184
  }));
122
185
 
123
- return { refreshGit, unsubs };
186
+ return { refreshGit, unsubs, transcriptJournal };
124
187
  }
@@ -10,7 +10,6 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
10
10
  name: 'cockpit',
11
11
  aliases: [],
12
12
  description: 'Open the unified operator cockpit',
13
- usage: '',
14
13
  handler(_args, ctx) {
15
14
  if (ctx.openCockpitPanel) {
16
15
  ctx.openCockpitPanel();
@@ -103,7 +102,6 @@ export function registerControlRoomRuntimeCommands(registry: CommandRegistry): v
103
102
  name: 'communication',
104
103
  aliases: ['comms'],
105
104
  description: 'Inspect structured agent communication routes and recent activity',
106
- usage: '',
107
105
  handler(_args, ctx) {
108
106
  if (ctx.openCommunicationPanel) {
109
107
  ctx.openCommunicationPanel();
@@ -51,7 +51,7 @@ export function registerDiffRuntimeCommands(registry: CommandRegistry): void {
51
51
  registry.register({
52
52
  name: 'diff',
53
53
  aliases: ['d'],
54
- description: 'Show unified diff of session file changes. Uses git diff HEAD if in a git repo.',
54
+ description: 'Show unified diff of session file changes. Uses git diff HEAD if in a git repo',
55
55
  usage: '[session|head|working|staged|<git-ref>]',
56
56
  argsHint: '[session|head|working|staged|<ref>]',
57
57
  async handler(args, ctx) {
@@ -162,7 +162,7 @@ async function handleGate(args: string[], context: CommandContext): Promise<void
162
162
 
163
163
  export const evalCommand: SlashCommand = {
164
164
  name: 'eval',
165
- description: 'Evaluation harness: run benchmark suites, compare baselines, and gate regressions.',
165
+ description: 'Evaluation harness: run benchmark suites, compare baselines, and gate regressions',
166
166
  usage: '<subcommand> [args]',
167
167
  argsHint: 'list|run <suite>|compare <baseline>|gate <suite>',
168
168
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -1,5 +1,6 @@
1
1
  import { ServiceRegistry } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
3
+ import { probeTermCaps } from '../../renderer/term-caps.ts';
3
4
  import { evaluateSessionMaintenance, formatSessionMaintenanceLines } from '@/runtime/index.ts';
4
5
  import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core';
5
6
  import type { CommandRegistry } from '../command-registry.ts';
@@ -39,7 +40,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
39
40
  name: 'health',
40
41
  aliases: ['doctor'],
41
42
  description: 'Health workspace for startup posture, service readiness, sandbox posture, and provider health',
42
- usage: '[open|review|setup|services|sandbox|provider|accounts|auth|settings|intelligence|remote|mcp|continuity|worktrees|maintenance|repair [domain]]',
43
+ usage: '[open|review|setup|services|sandbox|provider|accounts|auth|settings|intelligence|remote|mcp|continuity|worktrees|maintenance|term|repair [domain]]',
43
44
  async handler(args, ctx) {
44
45
  const sub = (args[0] ?? 'review').toLowerCase();
45
46
  const readModels = requireReadModels(ctx);
@@ -127,8 +128,8 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
127
128
  ` recent failures: ${settings.recentFailureCount}`,
128
129
  ` staged bundle: ${settings.hasStagedManagedBundle ? 'present' : 'none'}`,
129
130
  ...(issues.length > 0 ? issues.map((issue) => ` issue: ${issue}`) : [' no active settings-control issues detected']),
130
- ' next: /settingssync panel',
131
- ' next: /settingssync show <key>',
131
+ ' next: /settings-sync panel',
132
+ ' next: /settings-sync show <key>',
132
133
  ' next: /managed staged',
133
134
  ].join('\n'));
134
135
  return;
@@ -282,6 +283,23 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
282
283
  return;
283
284
  }
284
285
 
286
+ if (sub === 'term') {
287
+ const caps = probeTermCaps(process.stdout as NodeJS.WriteStream);
288
+ const issues: string[] = [];
289
+ if (caps.capability === 'none') issues.push('terminal reports no color support — UI rendering will be degraded (no ANSI colors)');
290
+ if (caps.capability === 'basic16') issues.push('terminal limited to 16 ANSI colors — gradient and true-color UI elements will be approximated');
291
+ if (!caps.syncedOutput) issues.push('DEC Synchronized Output (mode 2026) is disabled — screen-tearing may be visible on slow connections');
292
+ ctx.print([
293
+ 'Health Review: Terminal Capabilities',
294
+ ` color capability: ${caps.capability}`,
295
+ ` synced output (mode 2026): ${caps.syncedOutput ? 'enabled' : 'disabled'}`,
296
+ ` NO_COLOR env: ${process.env['NO_COLOR'] !== undefined && process.env['NO_COLOR'] !== '' ? 'set (forces none)' : 'unset'}`,
297
+ ` TERM env: ${process.env['TERM'] ?? '(unset)'}`,
298
+ ...(issues.length > 0 ? issues.map((issue) => ` issue: ${issue}`) : [' no terminal capability issues detected']),
299
+ ].join('\n'));
300
+ return;
301
+ }
302
+
285
303
  if (sub === 'repair') {
286
304
  const domain = (args[1] ?? 'review').toLowerCase();
287
305
  const lines = ['Health Repair'];
@@ -290,7 +308,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
290
308
  lines.push(' domain: settings');
291
309
  lines.push(...(
292
310
  settings.conflicts.length > 0
293
- ? [' /settingssync panel', ' /settingssync show <key>', ' /managed staged']
311
+ ? [' /settings-sync panel', ' /settings-sync show <key>', ' /managed staged']
294
312
  : [' no active settings repair actions suggested']
295
313
  ));
296
314
  lines.push(' verify: /health settings');
@@ -426,6 +444,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
426
444
  ' /health remote',
427
445
  ' /health maintenance',
428
446
  ' /health worktrees',
447
+ ' /health term',
429
448
  ' /health repair <domain>',
430
449
  ' /setup onboarding',
431
450
  ].join('\n'));
@@ -132,7 +132,7 @@ function renderKnowledgeAskResult(result: KnowledgeAskResult): string {
132
132
  export const knowledgeCommand: SlashCommand = {
133
133
  name: 'knowledge',
134
134
  aliases: ['know'],
135
- description: 'Structured knowledge graph: ingest URLs/bookmarks, inspect issues, and build compact prompt packets.',
135
+ description: 'Structured knowledge graph: ingest URLs/bookmarks, inspect issues, and build compact prompt packets',
136
136
  usage: '<subcommand> [args]',
137
137
  argsHint: 'status|ask|ingest-url|import-bookmarks|import-urls|list|search|get|queue|review-issue|candidates|reports|schedules|lint|packet|explain|reindex|consolidate',
138
138
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -53,7 +53,6 @@ export function registerLocalRuntimeCommands(registry: CommandRegistry): void {
53
53
  name: 'incident-review',
54
54
  aliases: [],
55
55
  description: 'Alias for /incident open',
56
- usage: '',
57
56
  handler(_args, ctx) {
58
57
  if (ctx.openIncidentPanel) {
59
58
  ctx.openIncidentPanel();
@@ -249,7 +248,7 @@ export function registerLocalRuntimeCommands(registry: CommandRegistry): void {
249
248
  aliases: ['img'],
250
249
  description: 'Attach an image file to the next message',
251
250
  usage: '<path> [prompt text]',
252
- argsHint: '<path> [prompt]',
251
+ argsHint: '<path> [prompt text]',
253
252
  async handler(args, ctx) {
254
253
  if (args.length === 0) {
255
254
  ctx.print('Usage: /image <path> [prompt text]\nSupported formats: PNG, JPEG, WebP, GIF');
@@ -55,7 +55,7 @@ export function registerMemoryProductRuntimeCommands(registry: CommandRegistry):
55
55
 
56
56
  registry.register({
57
57
  name: 'session-memory',
58
- description: 'Dedicated front-door for session-scoped memory capture and review. All subcommands are filtered to scope=session.',
58
+ description: 'Dedicated front-door for session-scoped memory capture and review. All subcommands are filtered to scope=session',
59
59
  usage: '[queue [limit] | export <path> | add <class> <summary...>]',
60
60
  async handler(args, ctx) {
61
61
  const sub = (args[0] ?? 'queue').toLowerCase();
@@ -82,7 +82,7 @@ export function registerMemoryProductRuntimeCommands(registry: CommandRegistry):
82
82
 
83
83
  registry.register({
84
84
  name: 'team-memory',
85
- description: 'Dedicated front-door for team/shared memory review and exchange. The queue and export subcommands are filtered to scope=team.',
85
+ description: 'Dedicated front-door for team/shared memory review and exchange. The queue and export subcommands are filtered to scope=team',
86
86
  usage: '[queue [limit] | export <path> | import <path> | capture policy]',
87
87
  async handler(args, ctx) {
88
88
  const sub = (args[0] ?? 'queue').toLowerCase();
@@ -25,7 +25,7 @@ import { VALID_CLASSES, VALID_REVIEW_STATES, VALID_SCOPES } from './recall-share
25
25
  export const recallCommand: SlashCommand = {
26
26
  name: 'recall',
27
27
  aliases: ['rc'],
28
- description: 'Project memory: add decisions, constraints, incidents, and patterns with provenance.',
28
+ description: 'Project memory: add decisions, constraints, incidents, and patterns with provenance',
29
29
  usage: '<subcommand> [args]',
30
30
  argsHint: 'add|search|link|get|list|remove',
31
31
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -5,7 +5,6 @@ export function registerOnboardingRuntimeCommands(registry: CommandRegistry): vo
5
5
  registry.register({
6
6
  name: 'onboarding',
7
7
  description: 'Open the onboarding wizard with current settings preloaded for review and editing',
8
- usage: '',
9
8
  handler(_args, ctx) {
10
9
  openOnboardingWizard(ctx, { mode: 'edit', reset: true });
11
10
  ctx.print('Opening onboarding wizard.');
@@ -4,7 +4,7 @@ import { dispatchPolicyCommand } from './policy-dispatch.ts';
4
4
  export const policyCommand: SlashCommand = {
5
5
  name: 'policy',
6
6
  aliases: ['pol'],
7
- description: 'Open the policy panel or manage versioned policy bundles (load, simulate, diff, promote, rollback).',
7
+ description: 'Open the policy panel or manage versioned policy bundles (load, simulate, diff, promote, rollback)',
8
8
  usage: '<subcommand> [args]',
9
9
  argsHint: 'load|simulate|diff|lint|preflight|promote|rollback|status',
10
10
  handler: async (args: string[], context: CommandContext): Promise<void> => {
@@ -16,7 +16,8 @@ function inspectProfileSyncBundle(bundle: ProfileSyncBundle): string {
16
16
 
17
17
  export function registerProfileSyncRuntimeCommands(registry: CommandRegistry): void {
18
18
  registry.register({
19
- name: 'profilesync',
19
+ name: 'profile-sync',
20
+ aliases: ['profilesync'],
20
21
  description: 'Export, import, and inspect profile sync bundles',
21
22
  usage: '[list|export <path>|inspect <path>|import <path> [prefix]]',
22
23
  handler(args, ctx) {
@@ -36,7 +37,7 @@ export function registerProfileSyncRuntimeCommands(registry: CommandRegistry): v
36
37
 
37
38
  const pathArg = args[1];
38
39
  if (!pathArg) {
39
- ctx.print(`Usage: /profilesync ${sub} <path>${sub === 'import' ? ' [prefix]' : ''}`);
40
+ ctx.print(`Usage: /profile-sync ${sub} <path>${sub === 'import' ? ' [prefix]' : ''}`);
40
41
  return;
41
42
  }
42
43
  const targetPath = shellPaths.resolveWorkspacePath(pathArg);
@@ -93,7 +94,7 @@ export function registerProfileSyncRuntimeCommands(registry: CommandRegistry): v
93
94
  }
94
95
 
95
96
  recordSettingsSyncFailure('profiles', `unsupported subcommand: ${sub}`, controlPlaneConfigDir);
96
- ctx.print('Usage: /profilesync [list|export <path>|inspect <path>|import <path> [prefix]]');
97
+ ctx.print('Usage: /profile-sync [list|export <path>|inspect <path>|import <path> [prefix]]');
97
98
  },
98
99
  });
99
100
  }