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,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.
|
|
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
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2319
|
-
|
|
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,
|