polygram 0.22.0 → 0.23.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.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -3
  3. package/config.example.json +23 -1
  4. package/lib/codex/binary.js +287 -0
  5. package/lib/codex/delivery-finalizer.js +97 -0
  6. package/lib/codex/diagnostics.js +483 -0
  7. package/lib/codex/event-normalizer.js +50 -0
  8. package/lib/codex/host-identity.js +103 -0
  9. package/lib/codex/model-catalog.js +359 -0
  10. package/lib/codex/retention-scheduler.js +59 -0
  11. package/lib/codex/runtime-controller.js +1292 -0
  12. package/lib/codex/runtime-profile.js +888 -0
  13. package/lib/codex/spawn-context.js +87 -0
  14. package/lib/db/auto-resume.js +77 -9
  15. package/lib/db/codex-reconciliation.js +490 -0
  16. package/lib/db/codex-retention.js +139 -0
  17. package/lib/db/sessions.js +102 -1
  18. package/lib/db.js +3418 -19
  19. package/lib/error/classify.js +140 -0
  20. package/lib/handlers/abort.js +143 -5
  21. package/lib/handlers/autosteer.js +256 -1
  22. package/lib/handlers/codex-reconciliation.js +230 -0
  23. package/lib/handlers/config-callback.js +324 -89
  24. package/lib/handlers/config-ui.js +401 -39
  25. package/lib/handlers/dispatcher.js +71 -8
  26. package/lib/handlers/edit-correction.js +11 -1
  27. package/lib/handlers/edit-redelivery.js +230 -4
  28. package/lib/handlers/gate-inbound.js +7 -0
  29. package/lib/handlers/replay-disposition.js +133 -11
  30. package/lib/handlers/slash-commands.js +443 -37
  31. package/lib/ipc/client.js +22 -12
  32. package/lib/ipc/runtime-directory.js +208 -0
  33. package/lib/ipc/server.js +18 -12
  34. package/lib/model-costs.js +4 -3
  35. package/lib/pm-interface.js +33 -0
  36. package/lib/prompt.js +15 -9
  37. package/lib/rewind/rewind.js +21 -1
  38. package/lib/runtime-config.js +605 -0
  39. package/lib/sdk/build-options.js +4 -1
  40. package/lib/sdk/callbacks.js +46 -3
  41. package/migrations/015-agent-runtime-sessions.sql +50 -0
  42. package/migrations/016-agent-turn-attempts.sql +211 -0
  43. package/migrations/017-config-runtime-changes.sql +46 -0
  44. package/ops/README.md +57 -0
  45. package/package.json +2 -2
  46. package/polygram.js +697 -48
  47. package/scripts/doctor.js +37 -8
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const DEFAULT_DIRECTORY_NAME = '.ipc';
8
+ const BOT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
9
+ const MAX_BOT_NAME_BYTES = 64;
10
+ const MAX_UNIX_SOCKET_PATH_BYTES = 103;
11
+
12
+ function ipcDirectoryError(code, message) {
13
+ const error = new Error(message);
14
+ error.code = code;
15
+ return error;
16
+ }
17
+
18
+ function currentUid() {
19
+ if (typeof process.getuid !== 'function') {
20
+ throw ipcDirectoryError(
21
+ 'IPC_DIR_UID_UNAVAILABLE',
22
+ 'IPC directory ownership cannot be verified on this platform',
23
+ );
24
+ }
25
+ return process.getuid();
26
+ }
27
+
28
+ function isWithin(candidate, root) {
29
+ const relative = path.relative(root, candidate);
30
+ return (
31
+ relative === ''
32
+ || (
33
+ !relative.startsWith(`..${path.sep}`)
34
+ && relative !== '..'
35
+ && !path.isAbsolute(relative)
36
+ )
37
+ );
38
+ }
39
+
40
+ function existingRealpath(candidate) {
41
+ try {
42
+ return fs.realpathSync.native(candidate);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function temporaryRoots() {
49
+ const roots = new Set([
50
+ '/tmp',
51
+ '/private/tmp',
52
+ '/var/tmp',
53
+ '/private/var/tmp',
54
+ path.resolve(os.tmpdir()),
55
+ ]);
56
+ for (const root of [...roots]) {
57
+ const canonical = existingRealpath(root);
58
+ if (canonical) roots.add(canonical);
59
+ }
60
+ return [...roots];
61
+ }
62
+
63
+ function assertNonTemporary(candidate) {
64
+ const paths = [candidate];
65
+ const canonical = existingRealpath(candidate);
66
+ if (canonical) paths.push(canonical);
67
+ if (paths.some((item) => temporaryRoots().some((root) => isWithin(item, root)))) {
68
+ throw ipcDirectoryError(
69
+ 'IPC_DIR_TEMPORARY',
70
+ 'IPC directory must not be located in a temporary directory',
71
+ );
72
+ }
73
+ }
74
+
75
+ function assertOwnedDirectory(candidate, { exactMode = null, label = 'IPC directory' } = {}) {
76
+ let entry;
77
+ try {
78
+ entry = fs.lstatSync(candidate);
79
+ } catch (cause) {
80
+ throw ipcDirectoryError('IPC_DIR_UNAVAILABLE', `${label} is unavailable`);
81
+ }
82
+ if (entry.isSymbolicLink()) {
83
+ throw ipcDirectoryError('IPC_DIR_SYMLINK', `${label} must not be a symlink`);
84
+ }
85
+ if (!entry.isDirectory()) {
86
+ throw ipcDirectoryError('IPC_DIR_NOT_DIRECTORY', `${label} must be a directory`);
87
+ }
88
+
89
+ const canonical = existingRealpath(candidate);
90
+ if (!canonical || canonical !== candidate) {
91
+ throw ipcDirectoryError('IPC_DIR_NOT_CANONICAL', `${label} must be canonical and contain no symlinks`);
92
+ }
93
+
94
+ const stat = fs.statSync(candidate);
95
+ if (stat.uid !== currentUid()) {
96
+ throw ipcDirectoryError('IPC_DIR_WRONG_OWNER', `${label} must be owned by the current user`);
97
+ }
98
+ const mode = stat.mode & 0o777;
99
+ if (exactMode !== null && mode !== exactMode) {
100
+ throw ipcDirectoryError('IPC_DIR_WRONG_MODE', `${label} must have mode 0700`);
101
+ }
102
+ return stat;
103
+ }
104
+
105
+ function assertSafeCreationParent(candidate) {
106
+ const parent = path.dirname(candidate);
107
+ const stat = assertOwnedDirectory(parent, { label: 'IPC directory parent' });
108
+ if ((stat.mode & 0o022) !== 0) {
109
+ throw ipcDirectoryError(
110
+ 'IPC_DIR_UNSAFE_PARENT',
111
+ 'IPC directory parent must not be group- or world-writable',
112
+ );
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Resolve the one IPC runtime directory used by the daemon and its clients.
118
+ *
119
+ * The default stays under Polygram's DATA_DIR (`process.cwd()`), which is
120
+ * already outside agent write access. An override is accepted only as an
121
+ * absolute, canonical, non-temporary path.
122
+ */
123
+ function runtimeDirectory({ cwd = process.cwd(), env = process.env } = {}) {
124
+ const hasOverride = Object.prototype.hasOwnProperty.call(env, 'POLYGRAM_IPC_DIR');
125
+ const configured = hasOverride
126
+ ? env.POLYGRAM_IPC_DIR
127
+ : path.join(cwd, DEFAULT_DIRECTORY_NAME);
128
+
129
+ if (typeof configured !== 'string' || configured.length === 0) {
130
+ throw ipcDirectoryError('IPC_DIR_INVALID', 'IPC directory must be a non-empty path');
131
+ }
132
+ if (!path.isAbsolute(configured)) {
133
+ throw ipcDirectoryError('IPC_DIR_RELATIVE', 'IPC directory must be absolute');
134
+ }
135
+
136
+ const resolved = path.resolve(configured);
137
+ if (resolved !== configured) {
138
+ throw ipcDirectoryError('IPC_DIR_NOT_CANONICAL', 'IPC directory path must be canonical');
139
+ }
140
+ assertNonTemporary(resolved);
141
+ assertSafeCreationParent(resolved);
142
+
143
+ if (fs.existsSync(resolved)) {
144
+ assertOwnedDirectory(resolved, { exactMode: 0o700 });
145
+ }
146
+ return resolved;
147
+ }
148
+
149
+ /**
150
+ * Create the resolved runtime directory exactly once with owner-only access,
151
+ * then revalidate it to catch pre-existing or raced unsafe filesystem state.
152
+ */
153
+ function ensureRuntimeDirectory(options) {
154
+ const directory = runtimeDirectory(options);
155
+ if (!fs.existsSync(directory)) {
156
+ try {
157
+ fs.mkdirSync(directory, { mode: 0o700 });
158
+ } catch (error) {
159
+ if (error.code !== 'EEXIST') {
160
+ throw ipcDirectoryError('IPC_DIR_CREATE_FAILED', 'IPC directory could not be created');
161
+ }
162
+ }
163
+ }
164
+ assertOwnedDirectory(directory, { exactMode: 0o700 });
165
+ return directory;
166
+ }
167
+
168
+ function validateBotName(botName) {
169
+ if (
170
+ typeof botName !== 'string'
171
+ || Buffer.byteLength(botName, 'utf8') > MAX_BOT_NAME_BYTES
172
+ || !BOT_NAME_PATTERN.test(botName)
173
+ ) {
174
+ throw ipcDirectoryError(
175
+ 'IPC_BOT_NAME_INVALID',
176
+ 'IPC bot name may contain only letters, numbers, underscores, and hyphens',
177
+ );
178
+ }
179
+ return botName;
180
+ }
181
+
182
+ function socketPathFor(botName, options) {
183
+ const safeBotName = validateBotName(botName);
184
+ const socketPath = path.join(
185
+ runtimeDirectory(options),
186
+ `polygram-${safeBotName}.sock`,
187
+ );
188
+ if (Buffer.byteLength(socketPath, 'utf8') > MAX_UNIX_SOCKET_PATH_BYTES) {
189
+ throw ipcDirectoryError(
190
+ 'IPC_SOCKET_PATH_TOO_LONG',
191
+ 'IPC socket path exceeds the portable Unix-domain socket limit',
192
+ );
193
+ }
194
+ return socketPath;
195
+ }
196
+
197
+ function secretPathFor(botName, options) {
198
+ const safeBotName = validateBotName(botName);
199
+ return path.join(runtimeDirectory(options), `polygram-${safeBotName}.secret`);
200
+ }
201
+
202
+ module.exports = {
203
+ runtimeDirectory,
204
+ ensureRuntimeDirectory,
205
+ socketPathFor,
206
+ secretPathFor,
207
+ validateBotName,
208
+ };
package/lib/ipc/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Unix socket IPC server for the polygram daemon.
3
3
  *
4
- * One socket per bot process at `/tmp/polygram-<bot>.sock`. Clients
4
+ * One socket per bot process in Polygram's owner-only IPC runtime directory. Clients
5
5
  * (Claude Code approval hooks, future cron) send newline-delimited
6
6
  * JSON requests; the server invokes a registered handler and writes back a
7
7
  * newline-delimited JSON response.
@@ -13,14 +13,12 @@
13
13
  const net = require('net');
14
14
  const fs = require('fs');
15
15
  const crypto = require('crypto');
16
-
17
- function socketPathFor(botName) {
18
- return `/tmp/polygram-${botName}.sock`;
19
- }
20
-
21
- function secretPathFor(botName) {
22
- return `/tmp/polygram-${botName}.secret`;
23
- }
16
+ const {
17
+ runtimeDirectory,
18
+ ensureRuntimeDirectory,
19
+ socketPathFor,
20
+ secretPathFor,
21
+ } = require('./runtime-directory');
24
22
 
25
23
  /**
26
24
  * Generate and persist a fresh IPC secret. Written 0600 so only same-UID
@@ -29,9 +27,10 @@ function secretPathFor(botName) {
29
27
  * us a cheap way to reject stray processes that stumbled onto the socket
30
28
  * without intent — and makes the auth model explicit rather than implicit.
31
29
  */
32
- function writeSecret(botName) {
30
+ function writeSecret(botName, options) {
33
31
  const secret = crypto.randomBytes(32).toString('base64url');
34
- const p = secretPathFor(botName);
32
+ const p = secretPathFor(botName, options);
33
+ ensureRuntimeDirectory(options);
35
34
  fs.writeFileSync(p, secret, { mode: 0o600 });
36
35
  // Re-chmod in case umask overrode our mode on create.
37
36
  try { fs.chmodSync(p, 0o600); } catch {}
@@ -146,4 +145,11 @@ function writeReply(sock, obj) {
146
145
  } catch {}
147
146
  }
148
147
 
149
- module.exports = { start, socketPathFor, secretPathFor, writeSecret };
148
+ module.exports = {
149
+ start,
150
+ runtimeDirectory,
151
+ ensureRuntimeDirectory,
152
+ socketPathFor,
153
+ secretPathFor,
154
+ writeSecret,
155
+ };
@@ -28,10 +28,11 @@ const MODEL_COSTS = {
28
28
  'claude-haiku-4-5': { input: 0.80, output: 4, cacheRead: 0.08, cacheCreation: 1 },
29
29
  // Claude Opus 4.7 (1M context)
30
30
  'claude-opus-4-7': { input: 15, output: 75, cacheRead: 1.50, cacheCreation: 18.75 },
31
- // Claude Opus 4.8 — what `--model opus` resolves to now (verified in the
32
- // Music-topic transcript 2026-06-10). Same Opus pricing; without this entry
33
- // opus turns fall back to the Sonnet `default` and undercount cost ~5×.
31
+ // Claude Opus 4.8 — kept for turns recorded before the CLI pin moved to
32
+ // 2.1.220; without an entry those fall back to the Sonnet `default`.
34
33
  'claude-opus-4-8': { input: 15, output: 75, cacheRead: 1.50, cacheCreation: 18.75 },
34
+ // Claude Opus 5 — what `--model opus` resolves to on the pinned CLI.
35
+ 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.50, cacheCreation: 6.25 },
35
36
  // Default fallback — Sonnet rates (safest mid-tier estimate).
36
37
  default: { input: 3, output: 15, cacheRead: 0.30, cacheCreation: 3.75 },
37
38
  };
@@ -22,6 +22,10 @@
22
22
  * @property {string} sessionKey
23
23
  * @property {string|null} chatId
24
24
  * @property {string|null} threadId
25
+ * @property {string} label
26
+ * @property {'claude'|'codex'} runtime
27
+ * @property {string} backend
28
+ * @property {string|null} spawnProfileId
25
29
  * @property {boolean} closed
26
30
  * @property {boolean} inFlight
27
31
  * @property {Array<object>} pendingQueue — array of pending sends
@@ -53,6 +57,24 @@
53
57
  * @property {string|null} chatId
54
58
  * @property {string|null} threadId
55
59
  * @property {string} label
60
+ * @property {'claude'|'codex'} runtime
61
+ * Canonical runtime selected before process creation. Explicit Codex
62
+ * selection reaches Orchestra through this field; it is not passed to
63
+ * Claude's pickBackend() compatibility resolver.
64
+ * @property {string} backend
65
+ * Canonical provider backend (`sdk`, `cli`, or `codex`).
66
+ * @property {string|null} spawnProfileId
67
+ * Opaque identity from the successful runtime preflight. Codex process
68
+ * creation must receive the exact profile it was preflighted with.
69
+ * @property {string} [runtimeConfigIdentity]
70
+ * Polygram-owned identity of its resolved configuration. Orchestra ignores
71
+ * this field and enforces runtime/backend/spawnProfileId with its own
72
+ * identity; Polygram may use it for config audit or UI comparisons.
73
+ * @property {string|null} [cwd]
74
+ * Resolved topic/chat/default working directory included in spawn identity.
75
+ * @property {string|null} [agent]
76
+ * Resolved Claude agent. Always null for Codex; Codex instructions are
77
+ * attested independently and must not reuse Claude's named-agent bundle.
56
78
  *
57
79
  * @typedef {object} Pm
58
80
  * The unified ProcessManager interface.
@@ -61,6 +83,12 @@
61
83
  * @property {(sessionKey: string) => boolean} has
62
84
  * @property {(sessionKey: string) => PmEntry|null} get
63
85
  * @property {(sessionKey: string, ctx: PmSpawnContext) => Promise<PmEntry>} getOrSpawn
86
+ * @property {(sessionKey: string, ctx: PmSpawnContext) => Promise<PmEntry>} replaceRuntime
87
+ * — strict serialized replacement. Same identity returns the current entry.
88
+ * Live/pinned/blocked or conflicting replacement throws
89
+ * `RUNTIME_SWITCH_IN_FLIGHT`; unsafe retirement throws
90
+ * `RUNTIME_SWITCH_EVICTION_FAILED`; unknown runtime throws
91
+ * `RUNTIME_UNKNOWN`; provider lease/config errors propagate.
64
92
  * @property {(sessionKey: string, prompt: string, opts?: PmSendOptions) => Promise<PmSendResult>} send
65
93
  * @property {(sessionKey: string) => Promise<void>} kill
66
94
  * @property {(chatId: string|number) => Promise<void>} killChat
@@ -76,6 +104,11 @@
76
104
  * Returns false when sessionKey not found.
77
105
  * @property {((sessionKey: string, model: string) => Promise<boolean>)=} setModel
78
106
  * — Query.setModel live (no respawn).
107
+ * @property {((sessionKey: string, settings: {model: string, effort: string}) => Promise<object>)=} selectModelSettings
108
+ * @property {((sessionKey: string) => Promise<object>)=} getModelSettingsStatus
109
+ * — Atomically select the complete Codex pair for the next admitted turn.
110
+ * Expected lifecycle states return a discriminated `outcome`; malformed
111
+ * inputs and unexpected internal failures reject.
79
112
  * @property {((sessionKey: string, settings: {effortLevel?: string}) => Promise<boolean>)=} applyFlagSettings
80
113
  * — Query.applyFlagSettings live (no respawn).
81
114
  * @property {((sessionKey: string, mode: string) => Promise<boolean>)=} setPermissionMode
package/lib/prompt.js CHANGED
@@ -5,15 +5,19 @@
5
5
  * Telegram payload → polygram DB → unresolvable marker.
6
6
  */
7
7
 
8
- const { pickBackend } = require('@shumkov/orchestra');
8
+ const { resolveRuntimeDescriptor } = require('./runtime-config');
9
9
 
10
10
  function resolvePromptBackend({ config, chatId, threadId = null }) {
11
- return pickBackend({
11
+ const normalizedChatId = String(chatId);
12
+ const normalizedThreadId = threadId == null || threadId === ''
13
+ ? null
14
+ : String(threadId);
15
+ return resolveRuntimeDescriptor({
12
16
  config,
13
- chatId: String(chatId),
14
- threadId: threadId == null || threadId === '' ? null : String(threadId),
15
- pmDefault: 'sdk',
16
- });
17
+ chatId: normalizedChatId,
18
+ threadId: normalizedThreadId,
19
+ defaultPm: 'sdk',
20
+ }).backend;
17
21
  }
18
22
 
19
23
  /**
@@ -22,13 +26,15 @@ function resolvePromptBackend({ config, chatId, threadId = null }) {
22
26
  * must not be told to emit stickers it doesn't have. The emoji list is the bot's OWN
23
27
  * pack (its emojiToSticker keys), not a hard-coded set, so it always matches reality.
24
28
  *
25
- * @param {{ stickerEmojis?: string[], backend?: 'sdk'|'cli' }} [opts]
29
+ * @param {{ stickerEmojis?: string[], backend?: 'sdk'|'cli'|'codex' }} [opts]
26
30
  */
27
31
  function polygramInfo({ stickerEmojis = [], backend = 'sdk' } = {}) {
28
32
  const hasStickers = stickerEmojis.length > 0;
29
33
  const deliveryContract = backend === 'cli'
30
34
  ? `You are connected via a Telegram daemon (polygram) in channels mode. Inline final text is invisible to the Telegram user. For every user-visible response, you MUST call \`mcp__polygram-bridge__reply\` within this turn. Only a successful reply-tool receipt proves delivery. Do not claim something was already sent or posted above based only on the transcript; transcript content is not a delivery receipt. If delivery is uncertain, send it now with the reply tool.`
31
- : `You are connected via a Telegram daemon (polygram). Just reply with text — polygram delivers your response automatically. Do NOT use Telegram MCP tools.`;
35
+ : backend === 'codex'
36
+ ? `You are connected to Telegram through polygram's Codex app-server runtime. Reply with inline text — polygram streams and delivers your final response automatically. Do NOT use Telegram MCP tools. This is a native macOS beta: command network and model-native web search are disabled, and product MCP tools and interactive approvals are unavailable. Keep long-running commands in the foreground. Do not start detached or background servers; they are unsupported and may survive hard runtime loss.`
37
+ : `You are connected via a Telegram daemon (polygram). Just reply with text — polygram delivers your response automatically. Do NOT use Telegram MCP tools.`;
32
38
  const emojiLine = hasStickers
33
39
  ? `Single emoji reply = auto-converted: ${stickerEmojis.join('')} become your stickers, any other emoji (🔥👍💪❤️) becomes a reaction on the user's message.`
34
40
  : `Single emoji reply = auto-converted to a reaction on the user's message (any Telegram emoji: 🔥👍💪❤️ …).`;
@@ -196,7 +202,7 @@ function buildVoiceTags(attachments) {
196
202
  * @param {string} params.sessionCtx - session context file contents (optional)
197
203
  * @param {Array} params.attachments - downloaded attachments
198
204
  * @param {Object} params.replyTo - input for buildReplyToBlock (optional)
199
- * @param {'sdk'|'cli'} params.backend - delivery contract for the selected process backend
205
+ * @param {'sdk'|'cli'|'codex'} params.backend - delivery contract for the selected process backend
200
206
  */
201
207
  function buildPrompt({ msg, topicName = '', sessionCtx = '', attachments = [], replyTo = null, polygramHistory = '', stickerEmojis = [], backend = 'sdk' }) {
202
208
  const chatId = msg.chat.id.toString();
@@ -148,8 +148,28 @@ function createRewindHandler({ pm, tg, bot, botName = 'bot', executeRewind, logE
148
148
  * Dispatcher hook. Returns { consumed }. Consumes any `/rewind` (valid → queued, invalid →
149
149
  * the operator is told why) so it never starts a normal turn.
150
150
  */
151
- async function tryConsume({ sessionKey, chatId, threadId = null, msg, cleanText, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode }) {
151
+ async function tryConsume({
152
+ sessionKey,
153
+ chatId,
154
+ threadId = null,
155
+ msg,
156
+ cleanText,
157
+ runtime = 'claude',
158
+ botUsername,
159
+ rewindSafe,
160
+ isOperatorIdentity,
161
+ paired,
162
+ accessMode,
163
+ }) {
152
164
  if (!isRewindCommand(cleanText)) return { consumed: false };
165
+ if (runtime === 'codex') {
166
+ await ack(
167
+ chatId,
168
+ threadId,
169
+ 'Codex thread rewind is not supported yet; the active Codex thread was unchanged.',
170
+ );
171
+ return { consumed: true };
172
+ }
153
173
  const gate = gateRewindRequest({ msg, botUsername, rewindSafe, isOperatorIdentity, paired, accessMode });
154
174
  if (!gate.ok) {
155
175
  await ack(chatId, threadId, `↩️ ${gate.reason}`);