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,303 @@
1
+ /**
2
+ * Low-level tmux wrapper used by TmuxProcess (`lib/process/tmux-process.js`).
3
+ *
4
+ * Pure mechanics — spawn / send / capture / kill / list. No semantics
5
+ * about claude or polygram. TmuxProcess composes these into the
6
+ * higher-level send-prompt / observe-turn / interrupt flow.
7
+ *
8
+ * Conventions:
9
+ * - Session names are bot-prefixed to avoid cross-bot collision
10
+ * on the same host: `polygram-<bot>-<chat>-<thread>`.
11
+ * - Prompt bodies go through pasteText() (sanitize + multiline
12
+ * separator + set-buffer/paste-buffer).
13
+ * - Control keys (Enter, Escape, C-c) go through sendControl()
14
+ * using `tmux send-keys` (no -l flag).
15
+ *
16
+ * Phase 0 spike findings encoded here:
17
+ * F-spike-1 --permission-mode acceptEdits handled by callers
18
+ * F-spike-3 `\n` in paste-buffer SPLITS into separate Enter
19
+ * presses → encode as MULTILINE_SEPARATOR before paste
20
+ * F-spike-4 bypassPermissions needs --dangerously-skip-permissions
21
+ * companion (callers add this flag pair when needed)
22
+ * G5b sanitize() strips C0/DEL control bytes so a Telegram
23
+ * user can't inject Ctrl-C / Ctrl-D into the pty
24
+ *
25
+ * @see docs/0.10.0-phase0-spike-findings.md F-spike-1..4
26
+ * @see docs/0.10.0-process-manager-abstraction-plan.md §12.4
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const childProcess = require('child_process');
32
+ const crypto = require('crypto');
33
+ const fs = require('fs');
34
+ const path = require('path');
35
+
36
+ // ─── Constants ───────────────────────────────────────────────────────
37
+
38
+ // Phase 0 F-spike-3: `\n` in paste-buffer triggers separate Enter
39
+ // presses in claude TUI; only the LAST line stays. Encode as a visible
40
+ // separator before paste so the full multi-line prompt arrives.
41
+ const MULTILINE_SEPARATOR = ' / ';
42
+
43
+ // G5b: strip C0/DEL bytes (0x00-0x08, 0x0b-0x1f, 0x7f) from prompt
44
+ // before send. Allows \t (0x09) and \n (0x0a) through; we handle \n
45
+ // via MULTILINE_SEPARATOR.
46
+ const CONTROL_CHAR_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
47
+
48
+ // ─── execFile wrapper ────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Promise-wrapped childProcess.execFile. Returns { stdout, stderr }.
52
+ * Rejects on non-zero exit with err.stdout + err.stderr attached.
53
+ */
54
+ function run(cmd, args, opts = {}) {
55
+ return new Promise((resolve, reject) => {
56
+ childProcess.execFile(cmd, args, { ...opts, encoding: 'utf8' }, (err, stdout, stderr) => {
57
+ if (err) {
58
+ err.stdout = stdout;
59
+ err.stderr = stderr;
60
+ return reject(err);
61
+ }
62
+ resolve({ stdout, stderr });
63
+ });
64
+ });
65
+ }
66
+
67
+ // ─── Helpers ─────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Strip C0/DEL control characters. Allow \t and \n (\n is then
71
+ * encoded to MULTILINE_SEPARATOR by pasteText below).
72
+ */
73
+ function sanitize(text) {
74
+ return String(text).replace(CONTROL_CHAR_RE, '');
75
+ }
76
+
77
+ /**
78
+ * Bot-prefixed tmux session name. Replaces unsafe chars with _ so
79
+ * the name is always a valid tmux session identifier.
80
+ *
81
+ * @param {string} botName
82
+ * @param {string|number} chatId
83
+ * @param {string|number|null} threadId
84
+ */
85
+ function sessionName(botName, chatId, threadId) {
86
+ const tail = threadId ? `${chatId}-${threadId}` : `${chatId}-main`;
87
+ return `polygram-${botName}-${tail}`.replace(/[^\w-]/g, '_');
88
+ }
89
+
90
+ /**
91
+ * Per-session debug-log path. Same sanitization as session name so
92
+ * an admin typo in a topic key can't path-traverse.
93
+ *
94
+ * Per R2-F5 the path lives under polygram's own data dir, not /tmp.
95
+ *
96
+ * @param {string} botName
97
+ * @param {string|number} chatId
98
+ * @param {string|number|null} threadId
99
+ * @param {string} [logsDir] base dir; default ~/.polygram/<bot>/logs
100
+ */
101
+ function debugLogPath(botName, chatId, threadId, logsDir) {
102
+ const safeBot = String(botName).replace(/[^\w-]/g, '_');
103
+ const tail = threadId ? `${chatId}-${threadId}` : `${chatId}-main`;
104
+ const safeTail = String(tail).replace(/[^\w-]/g, '_');
105
+ // SECURITY (audit M4): refuse to fall back to /tmp when HOME is
106
+ // unset. /tmp is world-writable; a co-tenant could pre-create the
107
+ // path as a symlink pointing at an arbitrary file, and claude's
108
+ // --debug-file flag would follow it on open-for-append. Operators
109
+ // running polygram must have HOME set; if not, this is a misconfig
110
+ // that should fail loud.
111
+ if (!logsDir && !process.env.HOME) {
112
+ throw Object.assign(
113
+ new Error('HOME env var unset; refusing /tmp fallback for debugLogPath'),
114
+ { code: 'HOME_UNSET' },
115
+ );
116
+ }
117
+ const base = logsDir || path.join(process.env.HOME, '.polygram', safeBot, 'logs');
118
+ return path.join(base, `tmux-claude-${safeTail}.log`);
119
+ }
120
+
121
+ /**
122
+ * Ensure the directory exists for a debug log path. Idempotent.
123
+ */
124
+ function ensureLogDir(logPath) {
125
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
126
+ }
127
+
128
+ // ─── TmuxRunner ──────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Construct a tmux runner. Returns an object of methods. Stateless —
132
+ * each call is an independent tmux invocation. The shared `logger` is
133
+ * the only injected dependency.
134
+ *
135
+ * Test seam: tests can stub `runner._run` to mock execFile.
136
+ *
137
+ * @param {object} [opts]
138
+ * @param {object} [opts.logger=console]
139
+ * @param {Function} [opts.runFn] — override the underlying execFile
140
+ * wrapper (for tests). Same signature: (cmd, args, opts?) → Promise.
141
+ */
142
+ function createTmuxRunner({ logger = console, runFn = run } = {}) {
143
+
144
+ async function spawn({
145
+ name,
146
+ cwd,
147
+ command,
148
+ args = [],
149
+ envExtras = {},
150
+ paneWidth = 200,
151
+ }) {
152
+ // SECURITY (audit M5): paneWidth ends up as a CLI arg to `tmux
153
+ // set-option`. We use execFile (no shell), so this is not a
154
+ // shell-injection vector — but a hostile value like '-Force' or
155
+ // a number-string with embedded options could be mis-parsed by
156
+ // tmux. Validate it's a small positive integer.
157
+ if (!Number.isInteger(paneWidth) || paneWidth < 20 || paneWidth > 10_000) {
158
+ throw new TypeError(`paneWidth must be an integer in [20, 10000], got ${paneWidth}`);
159
+ }
160
+ const sessArgs = ['new-session', '-d', '-s', name];
161
+ if (cwd) sessArgs.push('-c', cwd);
162
+ for (const [k, v] of Object.entries(envExtras)) {
163
+ sessArgs.push('-e', `${k}=${v}`);
164
+ }
165
+ sessArgs.push(command, ...args);
166
+ try {
167
+ await runFn('tmux', sessArgs);
168
+ } catch (err) {
169
+ throw Object.assign(new Error(`tmux spawn failed: ${err.message}`), {
170
+ code: 'TMUX_SPAWN_FAILED',
171
+ name,
172
+ cause: err,
173
+ stderr: err.stderr,
174
+ });
175
+ }
176
+ // Set a wide pane to reduce capture-pane wrap artifacts.
177
+ // Best-effort — if the set-option fails, capture-pane -J fallback
178
+ // in captureWide() handles the wrap case.
179
+ try {
180
+ await runFn('tmux', ['set-option', '-t', name, '-w', 'pane-width', String(paneWidth)]);
181
+ } catch (err) {
182
+ logger.warn?.(`[tmux-runner] set-option pane-width failed for ${name}: ${err.message}`);
183
+ }
184
+ return name;
185
+ }
186
+
187
+ /**
188
+ * Send raw key sequence (Enter, Escape, C-c, etc.). NOT for prompt
189
+ * body — use pasteText for that. send-keys without -l interprets
190
+ * key names like "Enter" and "C-c".
191
+ */
192
+ async function sendControl(name, key) {
193
+ await runFn('tmux', ['send-keys', '-t', name, key]);
194
+ }
195
+
196
+ /**
197
+ * Push a multi-line text prompt into the pane.
198
+ *
199
+ * 1. sanitize() strips C0/DEL bytes (G5b)
200
+ * 2. \n → MULTILINE_SEPARATOR (F-spike-3)
201
+ * 3. set-buffer + paste-buffer (atomic; bracketed-paste-aware
202
+ * in modern claude TUI versions)
203
+ *
204
+ * NO Enter is sent. Caller follows up with `sendControl(name, 'Enter')`
205
+ * when they want to submit. (Splitting paste + Enter lets callers
206
+ * verify the text landed via capture-pane before submitting.)
207
+ */
208
+ async function pasteText(name, text) {
209
+ const sanitized = sanitize(text);
210
+ const oneLine = sanitized.replace(/\r?\n/g, MULTILINE_SEPARATOR);
211
+ const bufName = `polygram-buf-${crypto.randomBytes(3).toString('hex')}`;
212
+ await runFn('tmux', ['set-buffer', '-b', bufName, oneLine]);
213
+ try {
214
+ // -d (delete after) so the buffer doesn't accumulate.
215
+ await runFn('tmux', ['paste-buffer', '-t', name, '-b', bufName, '-d']);
216
+ } catch (err) {
217
+ // Best-effort buffer cleanup if paste fails.
218
+ await runFn('tmux', ['delete-buffer', '-b', bufName]).catch(() => {});
219
+ throw err;
220
+ }
221
+ return { sanitized, oneLine, stripped: text.length - sanitized.length };
222
+ }
223
+
224
+ /**
225
+ * Capture pane content. By default returns the last 1000 lines with
226
+ * line-wrapped lines joined (`-J`) — handles wrapping artifacts
227
+ * regardless of pane-width setting.
228
+ *
229
+ * For frequent polling (ready/streaming/approval-prompt detection),
230
+ * pass a smaller `lines` value — the indicators all live in the
231
+ * bottom ~50 lines of the pane. Polling 1000 lines each poll spawns
232
+ * a heavier tmux capture subprocess unnecessarily.
233
+ */
234
+ async function capturePane(name, { lines = 1000, joinWrapped = true } = {}) {
235
+ const args = ['capture-pane', '-t', name, '-p'];
236
+ if (joinWrapped) args.push('-J');
237
+ args.push('-S', `-${lines}`);
238
+ const { stdout } = await runFn('tmux', args);
239
+ return stdout;
240
+ }
241
+
242
+ /**
243
+ * Wide capture — alias for capturePane with -J always on. Use when
244
+ * regex parsing is sensitive to line wrapping.
245
+ */
246
+ async function captureWide(name, opts = {}) {
247
+ return capturePane(name, { ...opts, joinWrapped: true });
248
+ }
249
+
250
+ async function sessionExists(name) {
251
+ try {
252
+ await runFn('tmux', ['has-session', '-t', name]);
253
+ return true;
254
+ } catch {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ async function killSession(name) {
260
+ await runFn('tmux', ['kill-session', '-t', name]).catch(() => {});
261
+ }
262
+
263
+ /**
264
+ * List polygram-managed tmux sessions on the host. Optional `botName`
265
+ * narrows the prefix; without it returns all `polygram-*` sessions.
266
+ */
267
+ async function listPolygramSessions(botName = null) {
268
+ try {
269
+ const { stdout } = await runFn('tmux', ['list-sessions', '-F', '#{session_name}']);
270
+ const all = stdout.trim().split('\n').filter(Boolean);
271
+ const prefix = botName ? `polygram-${String(botName).replace(/[^\w-]/g, '_')}-` : 'polygram-';
272
+ return all.filter((n) => n.startsWith(prefix));
273
+ } catch {
274
+ return [];
275
+ }
276
+ }
277
+
278
+ return {
279
+ spawn,
280
+ sendControl,
281
+ pasteText,
282
+ capturePane,
283
+ captureWide,
284
+ sessionExists,
285
+ killSession,
286
+ listPolygramSessions,
287
+ sessionName,
288
+ debugLogPath,
289
+ ensureLogDir,
290
+ sanitize,
291
+ // Test hook
292
+ _run: runFn,
293
+ };
294
+ }
295
+
296
+ module.exports = {
297
+ createTmuxRunner,
298
+ sessionName,
299
+ debugLogPath,
300
+ sanitize,
301
+ MULTILINE_SEPARATOR,
302
+ CONTROL_CHAR_RE,
303
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Normalize the tool-input string parsed from a claude TUI approval
3
+ * prompt into a canUseTool-shaped object — so the rest of polygram's
4
+ * approval plumbing (chat_tool_decisions persistence, approvalCardText
5
+ * rendering, matchesApprovalPattern gating) works uniformly across
6
+ * SDK and tmux backends.
7
+ *
8
+ * The TUI's invocation line looks like:
9
+ *
10
+ * ⏺ Bash(rm /tmp/foo.txt)
11
+ * ⏺ Read(/Users/x/y.txt)
12
+ * ⏺ Write(/path/file, "contents")
13
+ *
14
+ * What lands in `rawArg` is everything between the parens. Mapping is
15
+ * heuristic — the TUI doesn't tag args by name, so we map common tools
16
+ * to their known field shapes; anything else becomes `{ _raw: <str> }`
17
+ * so the approval card still renders something readable.
18
+ *
19
+ * The shape doesn't need to be byte-identical to what SDK's canUseTool
20
+ * receives — it only needs to be:
21
+ * - consistent enough that chat_tool_decisions can hash + match it
22
+ * - readable enough that the approval card body shows useful info
23
+ * - serializable to JSON
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ /**
29
+ * @param {string} toolName — e.g. 'Bash', 'Read', 'Write'
30
+ * @param {string} rawArg — the parenthesised content
31
+ * @returns {object}
32
+ */
33
+ function normalizeTuiToolInput(toolName, rawArg) {
34
+ const arg = typeof rawArg === 'string' ? rawArg : '';
35
+ switch (toolName) {
36
+ case 'Bash':
37
+ return { command: arg };
38
+ case 'Read':
39
+ case 'Glob':
40
+ return { file_path: arg };
41
+ case 'Write':
42
+ case 'Edit': {
43
+ // Best-effort split on first comma; the TUI doesn't escape so
44
+ // commands with commas inside arg #1 will misparse. The
45
+ // approval card still shows the raw shape, so the operator
46
+ // can read the actual command before approving.
47
+ const commaIdx = arg.indexOf(',');
48
+ if (commaIdx === -1) return { file_path: arg };
49
+ return {
50
+ file_path: arg.slice(0, commaIdx).trim(),
51
+ _raw_tail: arg.slice(commaIdx + 1).trim(),
52
+ };
53
+ }
54
+ case 'WebFetch':
55
+ case 'WebSearch':
56
+ return { url: arg };
57
+ default:
58
+ return { _raw: arg };
59
+ }
60
+ }
61
+
62
+ module.exports = { normalizeTuiToolInput };
@@ -0,0 +1,17 @@
1
+ -- 011-pm-backend.sql
2
+ --
3
+ -- 0.10.0: add pm_backend column to sessions table.
4
+ --
5
+ -- Lets boot-replay know which backend a session was last running under
6
+ -- (sdk | tmux). Phase 1 ships only the SDK backend, so existing rows
7
+ -- and new rows all get 'sdk'. Phase 2 starts populating 'tmux' for
8
+ -- chats with config.chats[X].pm = 'tmux'.
9
+ --
10
+ -- Invariant: the running session's backend MUST match what the DB
11
+ -- says. If config changes pm: sdk → pm: tmux mid-session, polygram
12
+ -- keeps the running session on SDK; only on /new or daemon restart
13
+ -- does the new choice take effect.
14
+ --
15
+ -- See docs/0.10.0-process-manager-abstraction-plan.md §6.5.
16
+
17
+ ALTER TABLE sessions ADD COLUMN pm_backend TEXT NOT NULL DEFAULT 'sdk';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.9.0",
3
+ "version": "0.10.0-rc.2",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -33,7 +33,17 @@ const { filterAttachments } = require('./lib/attachments');
33
33
  // callbacks), so the rest of polygram.js doesn't branch beyond the
34
34
  // pick-at-startup. Phase 4 deletes the CLI version after Phase 5
35
35
  // soak proves SDK stable. See docs/0.8.0-architecture-decisions.md.
36
- const { ProcessManagerSdk, extractAssistantText } = require('./lib/sdk/process-manager');
36
+ // 0.10.0: ProcessManager is generic (collection + LRU + dispatch).
37
+ // Process subclasses (SdkProcess now, TmuxProcess in Phase 2) provide
38
+ // per-session mechanics. The pre-0.10.0 monolithic ProcessManagerSdk
39
+ // is deleted; SdkProcess inherits its per-entry guts.
40
+ const { ProcessManager } = require('./lib/process-manager');
41
+ const { createProcessFactory } = require('./lib/process/factory');
42
+ const { extractAssistantText } = require('./lib/process/sdk-process');
43
+ const { createTmuxRunner } = require('./lib/tmux/tmux-runner');
44
+ const { sweepTmuxOrphans } = require('./lib/tmux/orphan-sweep');
45
+ const { normalizeTuiToolInput } = require('./lib/tmux/tui-tool-input');
46
+ const { PollScheduler } = require('./lib/tmux/poll-scheduler');
37
47
  // rc.42: autosteer-buffer module deleted. Native SDK priority push
38
48
  // (pm.injectUserMessage) replaces the buffer + PostToolBatch detour.
39
49
  const { createAutosteeredRefs } = require('./lib/autosteered-refs');
@@ -1113,29 +1123,34 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1113
1123
  // subsequent fires. Cleared on compact-boundary so the next
1114
1124
  // cycle (if it crosses again) will fire fresh.
1115
1125
  if (chatCtxHint === true && !contextHintShown.has(sessionKey)) {
1116
- const entry = pm.get(sessionKey);
1117
- const q = entry?.query;
1118
- if (q && typeof q.getContextUsage === 'function') {
1119
- const threshold = chatConfig.contextHintThreshold != null
1120
- ? chatConfig.contextHintThreshold
1121
- : (config.bot?.contextHintThreshold != null
1122
- ? config.bot.contextHintThreshold
1123
- : undefined);
1124
- q.getContextUsage().then((usage) => {
1125
- const text = maybeContextFullHint(usage, threshold != null ? { threshold } : undefined);
1126
- if (!text) return;
1127
- // Mark BEFORE the send so concurrent turns don't all
1128
- // fire the hint while the first one's still in flight.
1129
- contextHintShown.add(sessionKey);
1130
- return tg(bot, 'sendMessage', {
1131
- chat_id: chatId,
1132
- text,
1133
- ...(threadId ? { message_thread_id: threadId } : {}),
1134
- }, { source: 'context-full-hint', botName: BOT_NAME });
1135
- }).catch((err) => {
1136
- console.error(`[${label}] context-hint failed: ${err.message}`);
1137
- });
1138
- }
1126
+ // 0.10.0: route through pm.getContextUsage(sessionKey) instead
1127
+ // of poking entry.query directly. The pm-level call delegates
1128
+ // to Process.getContextUsage(), which is implemented by BOTH
1129
+ // SdkProcess (via Query.getContextUsage) and TmuxProcess (via
1130
+ // JSONL message.usage snapshots). Either backend returns the
1131
+ // same shape; either throws Unsupported when no data yet.
1132
+ const threshold = chatConfig.contextHintThreshold != null
1133
+ ? chatConfig.contextHintThreshold
1134
+ : (config.bot?.contextHintThreshold != null
1135
+ ? config.bot.contextHintThreshold
1136
+ : undefined);
1137
+ pm.getContextUsage(sessionKey).then((usage) => {
1138
+ const text = maybeContextFullHint(usage, threshold != null ? { threshold } : undefined);
1139
+ if (!text) return;
1140
+ // Mark BEFORE the send so concurrent turns don't all fire
1141
+ // the hint while the first one's still in flight.
1142
+ contextHintShown.add(sessionKey);
1143
+ return tg(bot, 'sendMessage', {
1144
+ chat_id: chatId,
1145
+ text,
1146
+ ...(threadId ? { message_thread_id: threadId } : {}),
1147
+ }, { source: 'context-full-hint', botName: BOT_NAME });
1148
+ }).catch((err) => {
1149
+ // UnsupportedOperation = backend doesn't have usage data
1150
+ // (yet) — silent no-op, not an error. Other errors surface.
1151
+ if (err?.code === 'UNSUPPORTED_OPERATION') return;
1152
+ console.error(`[${label}] context-hint failed: ${err.message}`);
1153
+ });
1139
1154
  }
1140
1155
  }
1141
1156
 
@@ -1860,6 +1875,20 @@ async function main() {
1860
1875
  console.log(`[orphan-guard] prior=${pidClaim.priorPid ?? '?'} action=${pidClaim.priorAction}`);
1861
1876
  }
1862
1877
 
1878
+ // 0.10.0: after claimPidFile kills the orphan daemon, sweep any
1879
+ // `polygram-<bot>-*` tmux sessions it left behind. Tmux sessions
1880
+ // outlive their parent process — without this, the new daemon's
1881
+ // TmuxProcess.start() hits EEXIST on session spawn for any chat
1882
+ // routed to pm:'tmux'. See lib/tmux/orphan-sweep.js for rationale.
1883
+ try {
1884
+ const sweep = await sweepTmuxOrphans({ botName: BOT_NAME, logger: console });
1885
+ if (sweep.swept.length > 0) {
1886
+ console.log(`[orphan-sweep] killed ${sweep.swept.length} stale tmux session(s)`);
1887
+ }
1888
+ } catch (err) {
1889
+ console.warn?.(`[orphan-sweep] failed (non-fatal): ${err.message}`);
1890
+ }
1891
+
1863
1892
  try {
1864
1893
  db = dbClient.open(DB_PATH);
1865
1894
  console.log(`[db] opened ${DB_PATH}`);
@@ -1956,7 +1985,12 @@ async function main() {
1956
1985
  extractAssistantText, getChatIdFromKey, getThreadIdFromKey,
1957
1986
  logger: console,
1958
1987
  });
1959
- Object.assign(pmOpts, sdkCallbacks);
1988
+ // 0.10.0: sdkCallbacks (the polygram-side lifecycle handlers — status
1989
+ // reactor, stream chunk → bubble edit, etc.) move from the underlying
1990
+ // SDK pm to the generic ProcessManager. The SDK pm gets legacyCallbacks
1991
+ // (a bridge that re-emits events on per-Process EventEmitters); the
1992
+ // generic pm subscribes to those EventEmitters and forwards to
1993
+ // sdkCallbacks. Same code path; one extra hop for the abstraction.
1960
1994
 
1961
1995
  ({
1962
1996
  makeCanUseTool,
@@ -1984,7 +2018,60 @@ async function main() {
1984
2018
  downloadAttachments = createDownloadAttachments({
1985
2019
  config, db, dbWrite, inboxDir: INBOX_DIR, logger: console,
1986
2020
  });
1987
- pm = new ProcessManagerSdk({ ...pmOpts, spawnFn: buildSdkOptions });
2021
+ // 0.10.0: one ProcessManager, holds Process instances (SdkProcess
2022
+ // today; TmuxProcess too in Phase 2). Factory mints the right
2023
+ // subclass per-chat based on config.chats[X].pm. Lifecycle events
2024
+ // (init / close / stream-chunk / result / tool-use / etc.) emit
2025
+ // from each Process; the pm forwards to sdkCallbacks.
2026
+ // tmux backend runner — one per daemon, shared across all TmuxProcess
2027
+ // instances. Construction is cheap (no system call until first
2028
+ // spawn/send). Only used if any chat in config has pm:'tmux'.
2029
+ const tmuxRunner = createTmuxRunner({ logger: console });
2030
+ // O1 optimization: shared poll-tick scheduler. N TmuxProcess
2031
+ // instances share ONE setInterval instead of spawning N independent
2032
+ // setTimeout chains. Idle when no chats are in flight (zero timers
2033
+ // running). Configurable via config.bot.tmuxPollIntervalMs.
2034
+ const tmuxPollIntervalMs = config.bot?.tmuxPollIntervalMs || 250;
2035
+ const pollScheduler = new PollScheduler({ intervalMs: tmuxPollIntervalMs });
2036
+ const processFactory = createProcessFactory({
2037
+ config,
2038
+ spawnFn: buildSdkOptions,
2039
+ db,
2040
+ logger: console,
2041
+ tmuxRunner,
2042
+ botName: BOT_NAME,
2043
+ pollScheduler,
2044
+ });
2045
+ // 0.10.0: route tmux backend's in-pane approval prompts through the
2046
+ // SAME canUseTool plumbing that SDK chats use. TmuxProcess emits
2047
+ // 'approval-required' with a respond() closure when it detects the
2048
+ // TUI's "Do you want to do this?" prompt; we call makeCanUseTool
2049
+ // (admin-chat card, chat_tool_decisions persistence, timeout race —
2050
+ // all reused from SDK) and feed its decision back to the TUI.
2051
+ sdkCallbacks.onApprovalRequired = async (sessionKey, payload) => {
2052
+ const { toolName, toolInput, id, respond } = payload || {};
2053
+ if (typeof respond !== 'function') return;
2054
+ try {
2055
+ const canUseTool = makeCanUseTool(sessionKey);
2056
+ const input = normalizeTuiToolInput(toolName, toolInput);
2057
+ const decision = await canUseTool(toolName, input, { toolUseID: id });
2058
+ const verdict = decision?.behavior === 'allow' ? 'allow' : 'deny';
2059
+ await respond(verdict, decision?.message);
2060
+ } catch (err) {
2061
+ console.error(`[approval-required] ${sessionKey} ${toolName} → ${err.message}`);
2062
+ // Fail-closed: deny with the error as feedback.
2063
+ try { await respond('deny', `approval-flow error: ${err.message}`); }
2064
+ catch { /* swallow */ }
2065
+ }
2066
+ };
2067
+
2068
+ pm = new ProcessManager({
2069
+ processFactory,
2070
+ db,
2071
+ logger: console,
2072
+ callbacks: sdkCallbacks,
2073
+ budget: cap,
2074
+ });
1988
2075
  // formatConfigInfoText MUST be wired BEFORE createHandleConfigCallback
1989
2076
  // — the latter destructures formatConfigInfoText from its deps at
1990
2077
  // call time and captures the value (closure-by-value). v4 reviewer
@@ -2315,14 +2402,16 @@ async function main() {
2315
2402
  if (o.text && savedSessionId) {
2316
2403
  try {
2317
2404
  const entry = await pm.getOrSpawn(o.session_key, buildSpawnContext(o.session_key));
2318
- if (!entry?.inputController?.push) {
2319
- throw new Error('input controller not ready post-spawn');
2405
+ // 0.10.0 P0.4: route through Process.fireUserMessage so both
2406
+ // SDK and tmux backends work. Pre-0.10.0-P0.4 reached into
2407
+ // entry.inputController.push directly — broken on tmux.
2408
+ if (!entry || typeof entry.fireUserMessage !== 'function') {
2409
+ throw new Error('Process.fireUserMessage not available');
2410
+ }
2411
+ const ok = entry.fireUserMessage(o.text);
2412
+ if (!ok) {
2413
+ throw new Error('fireUserMessage refused (closed or empty content)');
2320
2414
  }
2321
- entry.inputController.push({
2322
- type: 'user',
2323
- message: { role: 'user', content: o.text },
2324
- parent_tool_use_id: null,
2325
- });
2326
2415
  logEvent('compact-replay', {
2327
2416
  chat_id: o.chat_id,
2328
2417
  thread_id: o.thread_id,