polygram 0.9.0 → 0.10.0-rc.2
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/.claude-plugin/plugin.json +1 -1
- package/lib/db.js +14 -3
- package/lib/handlers/slash-commands.js +22 -12
- package/lib/model-costs.js +60 -0
- package/lib/process/factory.js +102 -0
- package/lib/process/process.js +193 -0
- package/lib/process/sdk-process.js +880 -0
- package/lib/process/tmux-process.js +1022 -0
- package/lib/process-manager.js +391 -0
- package/lib/sdk/callbacks.js +13 -5
- package/lib/tmux/log-tail.js +324 -0
- package/lib/tmux/orphan-sweep.js +79 -0
- package/lib/tmux/poll-scheduler.js +110 -0
- package/lib/tmux/session-log-parser.js +173 -0
- package/lib/tmux/tmux-runner.js +303 -0
- package/lib/tmux/tui-tool-input.js +62 -0
- package/migrations/011-pm-backend.sql +17 -0
- package/package.json +1 -1
- package/polygram.js +122 -33
- package/lib/sdk/process-manager.js +0 -1178
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProcessManager — generic collection of `Process` instances.
|
|
3
|
+
*
|
|
4
|
+
* Holds Map<sessionKey, Process>. Doesn't know or care which concrete
|
|
5
|
+
* Process subclass it's holding. SdkProcess + TmuxProcess both
|
|
6
|
+
* implement the same `lib/process/process.js` interface.
|
|
7
|
+
*
|
|
8
|
+
* Per-session dispatch (send, kill, interrupt, etc.) just delegates
|
|
9
|
+
* to the Process. Collection logic (LRU eviction, killChat, shutdown)
|
|
10
|
+
* lives here.
|
|
11
|
+
*
|
|
12
|
+
* Weighted LRU per Phase 0 F-spike-2: tmux backend is ~10× SDK pm's
|
|
13
|
+
* RSS (545MB vs 50MB). We evict to keep Σ Process.cost ≤ budget
|
|
14
|
+
* rather than count ≤ cap. Default: SDK cost=1, tmux cost=3,
|
|
15
|
+
* budget=10 → "10 SDK | 3 tmux | mixed in between."
|
|
16
|
+
*
|
|
17
|
+
* Lifecycle callbacks (onInit, onClose, onStreamChunk, etc.) get wired
|
|
18
|
+
* to each Process's EventEmitter at spawn. Process emits, pm forwards
|
|
19
|
+
* to operator's callback.
|
|
20
|
+
*
|
|
21
|
+
* Phase 1 only (this file): SDK-only factory; ProcessManager behaviour
|
|
22
|
+
* matches the current `lib/sdk/process-manager.js` API exactly. After
|
|
23
|
+
* Phase 1 lands and tests pass, the old per-bot pm class is deleted.
|
|
24
|
+
*
|
|
25
|
+
* See `docs/0.10.0-process-manager-abstraction-plan.md` for the full
|
|
26
|
+
* design.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const DEFAULT_BUDGET = 10; // total Σ cost (SDK cost=1, tmux cost=3)
|
|
32
|
+
const DEFAULT_LRU_WAIT_MS = 300_000;
|
|
33
|
+
|
|
34
|
+
// callback name → event name
|
|
35
|
+
const CALLBACK_TO_EVENT = {
|
|
36
|
+
onInit: 'init',
|
|
37
|
+
onClose: 'close',
|
|
38
|
+
onResult: 'result',
|
|
39
|
+
onStreamChunk: 'stream-chunk',
|
|
40
|
+
onToolUse: 'tool-use',
|
|
41
|
+
onAssistantMessageStart: 'assistant-message-start',
|
|
42
|
+
onAutonomousAssistantMessage: 'autonomous-assistant-message',
|
|
43
|
+
onCompactBoundary: 'compact-boundary',
|
|
44
|
+
onQueueDrop: 'queue-drop',
|
|
45
|
+
onThinking: 'thinking',
|
|
46
|
+
// Tmux backend: TUI shows in-pane approval prompt. SDK backend
|
|
47
|
+
// uses canUseTool callback directly (no event). Polygram wires
|
|
48
|
+
// onApprovalRequired to route tmux prompts through the SAME
|
|
49
|
+
// approval card UI used by SDK's canUseTool flow.
|
|
50
|
+
onApprovalRequired: 'approval-required',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
class ProcessManager {
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @param {(sessionKey: string, ctx: object) => Process} opts.processFactory
|
|
57
|
+
* — required. Returns a Process instance (not yet started).
|
|
58
|
+
* @param {number} [opts.budget=10] — weighted LRU budget
|
|
59
|
+
* @param {object} [opts.db] — used for _logEvent (matches today's pm)
|
|
60
|
+
* @param {object} [opts.logger=console]
|
|
61
|
+
* @param {object} [opts.callbacks={}] — keys: onInit, onClose, ...
|
|
62
|
+
* @param {number} [opts.lruWaitMs] — how long getOrSpawn parks
|
|
63
|
+
* when all entries are in-flight
|
|
64
|
+
*/
|
|
65
|
+
constructor({
|
|
66
|
+
processFactory,
|
|
67
|
+
budget = DEFAULT_BUDGET,
|
|
68
|
+
db,
|
|
69
|
+
logger = console,
|
|
70
|
+
callbacks = {},
|
|
71
|
+
lruWaitMs = DEFAULT_LRU_WAIT_MS,
|
|
72
|
+
} = {}) {
|
|
73
|
+
if (typeof processFactory !== 'function') {
|
|
74
|
+
throw new TypeError('ProcessManager: processFactory function required');
|
|
75
|
+
}
|
|
76
|
+
this.processFactory = processFactory;
|
|
77
|
+
this.budget = budget;
|
|
78
|
+
this.db = db;
|
|
79
|
+
this.logger = logger;
|
|
80
|
+
this.callbacks = { ...callbacks };
|
|
81
|
+
this.lruWaitMs = lruWaitMs;
|
|
82
|
+
this.procs = new Map(); // sessionKey → Process
|
|
83
|
+
this._lruWaiters = []; // [{ resolve, reject, timer }]
|
|
84
|
+
this._shuttingDown = false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Introspection ───────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
has(sessionKey) { return this.procs.has(sessionKey); }
|
|
90
|
+
get(sessionKey) { return this.procs.get(sessionKey) || null; }
|
|
91
|
+
keys() { return [...this.procs.keys()]; }
|
|
92
|
+
get size() { return this.procs.size; }
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Current total cost across all live processes.
|
|
96
|
+
*/
|
|
97
|
+
get totalCost() {
|
|
98
|
+
let sum = 0;
|
|
99
|
+
for (const p of this.procs.values()) {
|
|
100
|
+
if (!p.closed) sum += p.cost;
|
|
101
|
+
}
|
|
102
|
+
return sum;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── Spawn + LRU ─────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Returns the Process for sessionKey, spawning if absent.
|
|
109
|
+
* Evicts other processes (oldest non-in-flight first) to make room
|
|
110
|
+
* when adding a new Process would exceed budget.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} sessionKey
|
|
113
|
+
* @param {object} spawnContext — passed through to processFactory + start()
|
|
114
|
+
*/
|
|
115
|
+
async getOrSpawn(sessionKey, spawnContext) {
|
|
116
|
+
if (this._shuttingDown) throw new Error('shutdown');
|
|
117
|
+
|
|
118
|
+
const existing = this.procs.get(sessionKey);
|
|
119
|
+
if (existing && !existing.closed) return existing;
|
|
120
|
+
|
|
121
|
+
// Provisional new-process cost — ask the factory but don't start yet.
|
|
122
|
+
const newProc = this.processFactory(sessionKey, spawnContext);
|
|
123
|
+
const newCost = newProc.cost;
|
|
124
|
+
|
|
125
|
+
while (this.totalCost + newCost > this.budget) {
|
|
126
|
+
const evicted = this._evictLRU();
|
|
127
|
+
if (!evicted) {
|
|
128
|
+
// All entries in-flight — park.
|
|
129
|
+
await this._awaitLruSlot();
|
|
130
|
+
if (this._shuttingDown) {
|
|
131
|
+
try { await newProc.kill('shutdown'); } catch {}
|
|
132
|
+
throw new Error('shutdown');
|
|
133
|
+
}
|
|
134
|
+
// Loop again — budget may have freed up.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this._wireCallbacks(newProc);
|
|
139
|
+
this.procs.set(sessionKey, newProc);
|
|
140
|
+
newProc.lastUsedTs = Date.now();
|
|
141
|
+
try {
|
|
142
|
+
await newProc.start(spawnContext);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
this.procs.delete(sessionKey);
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
return newProc;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_evictLRU() {
|
|
151
|
+
let oldest = null;
|
|
152
|
+
let oldestKey = null;
|
|
153
|
+
for (const [k, p] of this.procs.entries()) {
|
|
154
|
+
if (p.inFlight) continue;
|
|
155
|
+
if (!oldest || (p.lastUsedTs || 0) < (oldest.lastUsedTs || 0)) {
|
|
156
|
+
oldest = p;
|
|
157
|
+
oldestKey = k;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!oldest) {
|
|
161
|
+
this._logEvent('lru-full', {
|
|
162
|
+
active: this.procs.size,
|
|
163
|
+
totalCost: this.totalCost,
|
|
164
|
+
budget: this.budget,
|
|
165
|
+
});
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
this._logEvent('evict', {
|
|
169
|
+
session_key: oldestKey,
|
|
170
|
+
cost: oldest.cost,
|
|
171
|
+
backend: oldest.backend,
|
|
172
|
+
});
|
|
173
|
+
oldest.kill('evict').catch(() => {});
|
|
174
|
+
this.procs.delete(oldestKey);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async _awaitLruSlot() {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
const idx = this._lruWaiters.findIndex((w) => w.resolve === resolve);
|
|
182
|
+
if (idx !== -1) this._lruWaiters.splice(idx, 1);
|
|
183
|
+
this._logEvent('lru-wait-timeout', { wait_ms: this.lruWaitMs });
|
|
184
|
+
reject(new Error(`lru wait timed out after ${this.lruWaitMs}ms`));
|
|
185
|
+
}, this.lruWaitMs);
|
|
186
|
+
this._lruWaiters.push({ resolve, reject, timer });
|
|
187
|
+
this._logEvent('lru-wait', {
|
|
188
|
+
active: this.procs.size,
|
|
189
|
+
totalCost: this.totalCost,
|
|
190
|
+
budget: this.budget,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_maybeSignalLruWaiter() {
|
|
196
|
+
const w = this._lruWaiters.shift();
|
|
197
|
+
if (w) { clearTimeout(w.timer); w.resolve(); }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Per-session dispatch ────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
async send(sessionKey, prompt, opts) {
|
|
203
|
+
const proc = this.procs.get(sessionKey);
|
|
204
|
+
if (!proc) throw new Error(`no process for sessionKey ${sessionKey}`);
|
|
205
|
+
proc.lastUsedTs = Date.now();
|
|
206
|
+
return proc.send(prompt, opts);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async kill(sessionKey, reason = 'kill') {
|
|
210
|
+
const proc = this.procs.get(sessionKey);
|
|
211
|
+
if (!proc) return false;
|
|
212
|
+
this.procs.delete(sessionKey);
|
|
213
|
+
try { await proc.kill(reason); } catch {}
|
|
214
|
+
this._maybeSignalLruWaiter();
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async killChat(chatId) {
|
|
219
|
+
const targets = [];
|
|
220
|
+
const idStr = String(chatId);
|
|
221
|
+
for (const [sk, p] of this.procs.entries()) {
|
|
222
|
+
if (p.chatId === idStr) targets.push([sk, p]);
|
|
223
|
+
}
|
|
224
|
+
for (const [sk] of targets) this.procs.delete(sk);
|
|
225
|
+
const results = await Promise.allSettled(
|
|
226
|
+
targets.map(([_, p]) => p.kill('killChat')),
|
|
227
|
+
);
|
|
228
|
+
for (let i = 0; i < targets.length; i++) {
|
|
229
|
+
this._maybeSignalLruWaiter();
|
|
230
|
+
}
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async shutdown() {
|
|
235
|
+
this._shuttingDown = true;
|
|
236
|
+
// Reject parked lru waiters.
|
|
237
|
+
for (const w of this._lruWaiters) {
|
|
238
|
+
clearTimeout(w.timer);
|
|
239
|
+
w.reject(new Error('shutdown'));
|
|
240
|
+
}
|
|
241
|
+
this._lruWaiters.length = 0;
|
|
242
|
+
|
|
243
|
+
const all = [...this.procs.values()];
|
|
244
|
+
this.procs.clear();
|
|
245
|
+
await Promise.allSettled(all.map((p) => p.kill('shutdown')));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Optional async — feature-detect at call site if needed ──────
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Shared dispatch for the five optional async methods. Returns the
|
|
252
|
+
* Process method's value on success, `unsupportedDefault` when the
|
|
253
|
+
* Process is missing/closed OR throws UNSUPPORTED_OPERATION /
|
|
254
|
+
* NOT_IMPLEMENTED_YET. Other errors propagate.
|
|
255
|
+
*/
|
|
256
|
+
async _invokeOptional(sessionKey, methodName, args, unsupportedDefault) {
|
|
257
|
+
const p = this.procs.get(sessionKey);
|
|
258
|
+
if (!p || p.closed) return unsupportedDefault;
|
|
259
|
+
try { return await p[methodName](...args); }
|
|
260
|
+
catch (err) {
|
|
261
|
+
if (err && (err.code === 'UNSUPPORTED_OPERATION' || err.code === 'NOT_IMPLEMENTED_YET')) {
|
|
262
|
+
return unsupportedDefault;
|
|
263
|
+
}
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async interrupt(sessionKey) {
|
|
269
|
+
return this._invokeOptional(sessionKey, 'interrupt', [], false);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async setModel(sessionKey, model) {
|
|
273
|
+
return this._invokeOptional(sessionKey, 'setModel', [model], false);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async applyFlagSettings(sessionKey, settings) {
|
|
277
|
+
return this._invokeOptional(sessionKey, 'applyFlagSettings', [settings], false);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async setPermissionMode(sessionKey, mode) {
|
|
281
|
+
return this._invokeOptional(sessionKey, 'setPermissionMode', [mode], false);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async resetSession(sessionKey, opts) {
|
|
285
|
+
const p = this.procs.get(sessionKey);
|
|
286
|
+
// No active process for this key — return no-op. Matches the
|
|
287
|
+
// pre-0.10.0 SDK pm semantic (`closed: false` = "we did not close
|
|
288
|
+
// anything"). Caller can distinguish "session was already gone"
|
|
289
|
+
// from "we just closed an active session."
|
|
290
|
+
if (!p) return { closed: false, drainedPendings: 0 };
|
|
291
|
+
try {
|
|
292
|
+
const result = await p.resetSession(opts);
|
|
293
|
+
// The Process's resetSession closes itself; remove from Map
|
|
294
|
+
// and signal LRU.
|
|
295
|
+
if (this.procs.get(sessionKey) === p) {
|
|
296
|
+
this.procs.delete(sessionKey);
|
|
297
|
+
}
|
|
298
|
+
this._maybeSignalLruWaiter();
|
|
299
|
+
return result;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (err.code === 'UNSUPPORTED_OPERATION' || err.code === 'NOT_IMPLEMENTED_YET') {
|
|
302
|
+
const drained = p.drainQueue('RESET_SESSION');
|
|
303
|
+
await this.kill(sessionKey, 'reset');
|
|
304
|
+
return { closed: true, drainedPendings: drained };
|
|
305
|
+
}
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async getContextUsage(sessionKey) {
|
|
311
|
+
return this._invokeOptional(sessionKey, 'getContextUsage', [], null);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── Optional sync hot-path — never throws (R1-F1) ───────────────
|
|
315
|
+
|
|
316
|
+
drainQueue(sessionKey, code = 'INTERRUPTED') {
|
|
317
|
+
const p = this.procs.get(sessionKey);
|
|
318
|
+
if (!p) return 0;
|
|
319
|
+
return p.drainQueue(code);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
injectUserMessage(sessionKey, opts) {
|
|
323
|
+
const p = this.procs.get(sessionKey);
|
|
324
|
+
if (!p || p.closed) return false;
|
|
325
|
+
return p.injectUserMessage(opts);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
steer(sessionKey, text, opts) {
|
|
329
|
+
const p = this.procs.get(sessionKey);
|
|
330
|
+
if (!p || p.closed) return false;
|
|
331
|
+
return p.steer(text, opts);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Internal helpers ────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* For each callback in this.callbacks, register a listener on the
|
|
338
|
+
* Process that forwards the event payload to the callback. Wire
|
|
339
|
+
* the standard event names; Process subclasses are free to emit
|
|
340
|
+
* additional events that pm doesn't forward.
|
|
341
|
+
*
|
|
342
|
+
* Also subscribes to 'idle' (Process became inFlight=false) and
|
|
343
|
+
* 'close' (Process closed itself) so the pm can signal parked
|
|
344
|
+
* LRU waiters + remove from the Map.
|
|
345
|
+
*/
|
|
346
|
+
_wireCallbacks(proc) {
|
|
347
|
+
for (const [cbName, eventName] of Object.entries(CALLBACK_TO_EVENT)) {
|
|
348
|
+
const fn = this.callbacks[cbName];
|
|
349
|
+
if (typeof fn !== 'function') continue;
|
|
350
|
+
proc.on(eventName, (...args) => {
|
|
351
|
+
try { fn(proc.sessionKey, ...args, proc); }
|
|
352
|
+
catch (err) {
|
|
353
|
+
this.logger.error?.(`[pm:${proc.label}] callback ${cbName} threw: ${err.message}`);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Generic 'error' channel — log + forward via onError if provided.
|
|
358
|
+
proc.on('error', (err) => {
|
|
359
|
+
this.logger.error?.(`[pm:${proc.label}] process error: ${err.message}`);
|
|
360
|
+
if (typeof this.callbacks.onError === 'function') {
|
|
361
|
+
try { this.callbacks.onError(proc.sessionKey, err, proc); }
|
|
362
|
+
catch (e) { this.logger.error?.(`[pm:${proc.label}] onError threw: ${e.message}`); }
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
// 'idle': a turn completed and pendingQueue is empty. Signal any
|
|
366
|
+
// parked LRU waiter that a non-in-flight slot is available.
|
|
367
|
+
proc.on('idle', () => this._maybeSignalLruWaiter());
|
|
368
|
+
// 'close': process closed itself (iteration loop exited or
|
|
369
|
+
// _closeQuery returned). Remove from the Map + signal LRU.
|
|
370
|
+
proc.on('close', () => {
|
|
371
|
+
if (this.procs.get(proc.sessionKey) === proc) {
|
|
372
|
+
this.procs.delete(proc.sessionKey);
|
|
373
|
+
}
|
|
374
|
+
this._maybeSignalLruWaiter();
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_logEvent(kind, detail) {
|
|
379
|
+
try {
|
|
380
|
+
this.db?.logEvent?.(kind, detail || {});
|
|
381
|
+
} catch (err) {
|
|
382
|
+
this.logger.error?.(`[pm] logEvent ${kind} failed: ${err.message}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
ProcessManager,
|
|
389
|
+
DEFAULT_BUDGET,
|
|
390
|
+
CALLBACK_TO_EVENT,
|
|
391
|
+
};
|
package/lib/sdk/callbacks.js
CHANGED
|
@@ -118,7 +118,12 @@ function createSdkCallbacks({
|
|
|
118
118
|
// but don't propagate.
|
|
119
119
|
onAutonomousAssistantMessage: (sessionKey, msg /* , entry */) => {
|
|
120
120
|
try {
|
|
121
|
-
|
|
121
|
+
// Backend-shape normalization: SDK emits the raw SDKMessage
|
|
122
|
+
// (text is inside content[]); tmux emits a pre-extracted
|
|
123
|
+
// {text, sessionId, backend}. Prefer the normalized field
|
|
124
|
+
// when present; fall back to SDK extraction.
|
|
125
|
+
const text = (msg && typeof msg.text === 'string' && msg.text)
|
|
126
|
+
|| extractAssistantText(msg);
|
|
122
127
|
if (!text) return;
|
|
123
128
|
const chatId = getChatIdFromKey(sessionKey);
|
|
124
129
|
const threadIdRaw = getThreadIdFromKey(sessionKey);
|
|
@@ -171,11 +176,14 @@ function createSdkCallbacks({
|
|
|
171
176
|
// fires AFTER compaction completes, leaving users confused
|
|
172
177
|
// when nothing followed. Now: distinguish manual vs auto and
|
|
173
178
|
// surface the compression ratio.
|
|
179
|
+
// Backend-shape normalization: SDK emits the raw SDKMessage
|
|
180
|
+
// with compact_metadata nested; tmux emits flat fields. Try
|
|
181
|
+
// top-level first (tmux), then nested (SDK).
|
|
174
182
|
const meta = msg?.compact_metadata || {};
|
|
175
|
-
const trigger = meta.trigger;
|
|
176
|
-
const preTokens = meta.pre_tokens;
|
|
177
|
-
const postTokens = meta.post_tokens;
|
|
178
|
-
const durationMs = meta.duration_ms;
|
|
183
|
+
const trigger = msg?.trigger ?? meta.trigger; // 'manual' | 'auto'
|
|
184
|
+
const preTokens = msg?.pre_tokens ?? meta.pre_tokens;
|
|
185
|
+
const postTokens = msg?.post_tokens ?? meta.post_tokens;
|
|
186
|
+
const durationMs = msg?.duration_ms ?? meta.duration_ms;
|
|
179
187
|
const fmtTok = (n) => {
|
|
180
188
|
if (n == null) return null;
|
|
181
189
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|