@pellux/goodvibes-tui 0.22.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 +47 -0
- package/README.md +17 -8
- package/package.json +1 -1
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management-utils.ts +352 -0
- package/src/cli/management.ts +116 -344
- package/src/cli/surface-command.ts +1 -1
- package/src/core/context-auto-compact.ts +43 -10
- package/src/core/conversation-rendering.ts +5 -2
- package/src/core/conversation-types.ts +24 -0
- package/src/core/conversation.ts +7 -12
- package/src/core/long-task-notifier.ts +145 -0
- package/src/core/session-recovery.ts +147 -0
- package/src/core/stream-event-wiring.ts +199 -7
- package/src/core/transcript-journal.ts +339 -0
- package/src/core/turn-event-wiring.ts +67 -4
- package/src/input/commands/channel-runtime.ts +139 -0
- 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/runtime-services.ts +30 -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/share-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +56 -6
- package/src/input/commands/work-plan-runtime.ts +8 -8
- package/src/input/commands.ts +2 -0
- 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 +50 -50
- 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/compaction-history-modal.ts +55 -0
- package/src/renderer/compaction-preview.ts +146 -0
- 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-helpers.ts +2 -2
- package/src/renderer/settings-modal.ts +1 -1
- package/src/renderer/ui-factory.ts +11 -0
- package/src/runtime/bootstrap-core.ts +92 -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/browser.ts +29 -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
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { CommandRegistry } from '../command-registry.ts';
|
|
2
|
+
import { requireIntegrationHelpers } from './runtime-services.ts';
|
|
3
|
+
|
|
4
|
+
export function registerChannelRuntimeCommands(registry: CommandRegistry): void {
|
|
5
|
+
registry.register({
|
|
6
|
+
name: 'channel',
|
|
7
|
+
aliases: [],
|
|
8
|
+
description: 'Inspect channel routes, delivery strategies, and ingress policies',
|
|
9
|
+
usage: '[status|routes|delivery|policy] [--json]',
|
|
10
|
+
argsHint: 'status | routes | delivery | policy',
|
|
11
|
+
handler(args, ctx) {
|
|
12
|
+
const sub = args[0];
|
|
13
|
+
const asJson = args.includes('--json');
|
|
14
|
+
|
|
15
|
+
if (!sub || sub === 'open' || sub === 'panel') {
|
|
16
|
+
if (ctx.showPanel) ctx.showPanel('routes');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const helpers = requireIntegrationHelpers(ctx);
|
|
21
|
+
|
|
22
|
+
if (sub === 'status') {
|
|
23
|
+
const review = helpers.buildReview();
|
|
24
|
+
if (asJson) {
|
|
25
|
+
ctx.print(JSON.stringify(review, null, 2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const lines: string[] = [
|
|
29
|
+
'Channel Status',
|
|
30
|
+
` routes: ${review.routes.length}`,
|
|
31
|
+
` api families: ${review.apiFamilies.join(', ') || '(none)'}`,
|
|
32
|
+
` sessions: ${review.sessions}`,
|
|
33
|
+
` tasks: ${review.tasks}`,
|
|
34
|
+
` pending approvals: ${review.pendingApprovals}`,
|
|
35
|
+
` remote contracts: ${review.remoteContracts}`,
|
|
36
|
+
'',
|
|
37
|
+
`Active route families: ${review.routes.join(', ') || '(none)'}`,
|
|
38
|
+
'',
|
|
39
|
+
'Use /channel routes for delivery binding details.',
|
|
40
|
+
'Use /channel delivery for outbound delivery snapshot.',
|
|
41
|
+
'Use /channel policy for ingress policy snapshot.',
|
|
42
|
+
];
|
|
43
|
+
ctx.print(lines.join('\n'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (sub === 'routes') {
|
|
48
|
+
const snapshot = helpers.getRouteSnapshot();
|
|
49
|
+
if (asJson) {
|
|
50
|
+
ctx.print(JSON.stringify(snapshot, null, 2));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const entries = Object.entries(snapshot);
|
|
54
|
+
if (entries.length === 0) {
|
|
55
|
+
ctx.print('No route bindings active.\n\nRoutes become active when channel surfaces (slack, discord, ntfy, webhook, etc.) are configured and the daemon is running.');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const lines: string[] = ['Channel Routes'];
|
|
59
|
+
for (const [key, value] of entries) {
|
|
60
|
+
lines.push(` ${String(key).padEnd(28)} ${JSON.stringify(value)}`);
|
|
61
|
+
}
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push('Route bindings reflect active daemon surface registrations.');
|
|
64
|
+
ctx.print(lines.join('\n'));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (sub === 'delivery') {
|
|
69
|
+
const snapshot = helpers.getDeliverySnapshot();
|
|
70
|
+
if (asJson) {
|
|
71
|
+
ctx.print(JSON.stringify(snapshot, null, 2));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const entries = Object.entries(snapshot);
|
|
75
|
+
if (entries.length === 0) {
|
|
76
|
+
ctx.print('No delivery snapshot available.\n\nDelivery state is populated when the daemon handles outbound channel messages.');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const lines: string[] = ['Channel Delivery Snapshot'];
|
|
80
|
+
for (const [key, value] of entries) {
|
|
81
|
+
lines.push(` ${String(key).padEnd(28)} ${JSON.stringify(value)}`);
|
|
82
|
+
}
|
|
83
|
+
ctx.print(lines.join('\n'));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (sub === 'policy') {
|
|
88
|
+
const configManager = ctx.platform.configManager;
|
|
89
|
+
// Channel policy is persisted by ChannelPolicyManager in
|
|
90
|
+
// .goodvibes/tui/channels/policies.json — surface via configManager
|
|
91
|
+
// category (runtime-accessible without a daemon round-trip).
|
|
92
|
+
const surfaces = [
|
|
93
|
+
'slack', 'discord', 'ntfy', 'webhook', 'homeassistant',
|
|
94
|
+
'telegram', 'google-chat', 'signal', 'whatsapp',
|
|
95
|
+
'imessage', 'msteams', 'bluebubbles', 'mattermost', 'matrix',
|
|
96
|
+
];
|
|
97
|
+
const lines: string[] = ['Channel Ingress Policies'];
|
|
98
|
+
let found = false;
|
|
99
|
+
for (const surface of surfaces) {
|
|
100
|
+
const key = `surfaces.${surface}.enabled` as Parameters<typeof configManager.get>[0];
|
|
101
|
+
const enabled = configManager.get(key);
|
|
102
|
+
if (enabled !== undefined && enabled !== null) {
|
|
103
|
+
found = true;
|
|
104
|
+
lines.push(` ${surface.padEnd(20)} enabled=${String(enabled)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!found) {
|
|
108
|
+
lines.push(' No channel surfaces configured.');
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push(' Configure surfaces via /onboarding or Settings > Surfaces.');
|
|
111
|
+
lines.push(' Fine-grained ingress policies (allowedCommands, requireMention, groupPolicies)');
|
|
112
|
+
lines.push(' are managed by ChannelPolicyManager in .goodvibes/tui/channels/policies.json.');
|
|
113
|
+
} else {
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push(' Fine-grained ingress policies (allowedCommands, requireMention, groupPolicies)');
|
|
116
|
+
lines.push(' are managed by ChannelPolicyManager in .goodvibes/tui/channels/policies.json.');
|
|
117
|
+
}
|
|
118
|
+
if (asJson) {
|
|
119
|
+
ctx.print(JSON.stringify({ surfaces: Object.fromEntries(surfaces.map((s) => [s, configManager.get(`surfaces.${s}.enabled` as Parameters<typeof configManager.get>[0])])) }, null, 2));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
ctx.print(lines.join('\n'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
ctx.print(
|
|
127
|
+
'Usage: /channel <subcommand>\n'
|
|
128
|
+
+ ' (no args) — open the Routes panel\n'
|
|
129
|
+
+ ' status — channel overview: routes, sessions, tasks, pending approvals\n'
|
|
130
|
+
+ ' routes — active route binding snapshot\n'
|
|
131
|
+
+ ' delivery — outbound delivery snapshot\n'
|
|
132
|
+
+ ' policy — configured channel surfaces and ingress policy location\n'
|
|
133
|
+
+ '\n'
|
|
134
|
+
+ 'Options:\n'
|
|
135
|
+
+ ' --json Output raw JSON for scripting'
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -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> => {
|