@pellux/goodvibes-tui 0.23.0 → 0.24.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.
- package/CHANGELOG.md +25 -0
- package/README.md +17 -8
- package/package.json +1 -1
- package/src/cli/management.ts +80 -10
- package/src/core/long-task-notifier.ts +145 -0
- package/src/core/session-recovery.ts +147 -0
- package/src/core/stream-event-wiring.ts +77 -3
- package/src/core/transcript-journal.ts +339 -0
- package/src/core/turn-event-wiring.ts +67 -4
- package/src/input/commands/control-room-runtime.ts +0 -2
- package/src/input/commands/diff-runtime.ts +1 -1
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +23 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-runtime.ts +1 -2
- package/src/input/commands/memory-product-runtime.ts +2 -2
- package/src/input/commands/memory.ts +1 -1
- package/src/input/commands/onboarding-runtime.ts +0 -1
- package/src/input/commands/policy.ts +1 -1
- package/src/input/commands/profile-sync-runtime.ts +4 -3
- package/src/input/commands/provider.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +0 -1
- package/src/input/commands/session-content.ts +2 -2
- package/src/input/commands/session-workflow.ts +32 -2
- package/src/input/commands/session.ts +1 -1
- package/src/input/commands/settings-sync-runtime.ts +9 -9
- package/src/input/commands/shell-core.ts +2 -2
- package/src/input/commands/work-plan-runtime.ts +8 -8
- package/src/input/feed-context-factory.ts +6 -0
- package/src/input/handler-feed-routes.ts +19 -1
- package/src/input/handler-feed.ts +11 -0
- package/src/input/handler-prompt-buffer.ts +28 -0
- package/src/input/handler-shortcuts.ts +88 -2
- package/src/input/handler-ui-state.ts +2 -2
- package/src/input/handler.ts +39 -3
- package/src/input/keybindings.ts +33 -3
- package/src/input/kill-ring.ts +134 -0
- package/src/input/model-picker.ts +18 -1
- package/src/input/search.ts +18 -6
- package/src/input/settings-modal-activation.ts +134 -0
- package/src/input/settings-modal-adjustment.ts +124 -0
- package/src/input/settings-modal-data.ts +53 -0
- package/src/input/settings-modal.ts +48 -145
- package/src/main.ts +33 -33
- package/src/panels/base-panel.ts +2 -1
- package/src/panels/provider-health-domains.ts +3 -3
- package/src/panels/provider-health-panel.ts +13 -9
- package/src/panels/provider-health-tracker.ts +7 -4
- package/src/panels/settings-sync-panel.ts +3 -3
- package/src/panels/work-plan-panel.ts +2 -2
- package/src/renderer/diff-view.ts +2 -2
- package/src/renderer/help-overlay.ts +1 -0
- package/src/renderer/model-picker-overlay.ts +23 -11
- package/src/renderer/progress.ts +3 -3
- package/src/renderer/search-overlay.ts +8 -5
- package/src/renderer/settings-modal.ts +1 -1
- package/src/renderer/ui-factory.ts +11 -0
- package/src/runtime/bootstrap-hook-bridge.ts +18 -0
- package/src/runtime/bootstrap-shell.ts +1 -0
- package/src/shell/blocking-input.ts +32 -0
- package/src/shell/recovery-input-helpers.ts +71 -0
- package/src/utils/terminal-width.ts +10 -3
- 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
|
-
|
|
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
|
-
|
|
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: /
|
|
131
|
-
' next: /
|
|
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
|
-
? [' /
|
|
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: '
|
|
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: /
|
|
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: /
|
|
97
|
+
ctx.print('Usage: /profile-sync [list|export <path>|inspect <path>|import <path> [prefix]]');
|
|
97
98
|
},
|
|
98
99
|
});
|
|
99
100
|
}
|