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.
@@ -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
+ };
@@ -118,7 +118,12 @@ function createSdkCallbacks({
118
118
  // but don't propagate.
119
119
  onAutonomousAssistantMessage: (sessionKey, msg /* , entry */) => {
120
120
  try {
121
- const text = extractAssistantText(msg);
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; // 'manual' | 'auto'
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`;