polygram 0.21.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 (48) 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/lib/util/claude-session-jsonl.js +3 -2
  42. package/migrations/015-agent-runtime-sessions.sql +50 -0
  43. package/migrations/016-agent-turn-attempts.sql +211 -0
  44. package/migrations/017-config-runtime-changes.sql +46 -0
  45. package/ops/README.md +57 -0
  46. package/package.json +2 -2
  47. package/polygram.js +697 -48
  48. package/scripts/doctor.js +37 -8
@@ -28,6 +28,14 @@
28
28
  'use strict';
29
29
 
30
30
  const { getConfigWriteScope } = require('../session-key');
31
+ const {
32
+ configuredRuntimeValue,
33
+ formatCodexSettingsOutcome,
34
+ getCodexCatalogEfforts,
35
+ getCodexCatalogModels,
36
+ isCodexRuntimeView,
37
+ resolveCodexEffortForModel,
38
+ } = require('./config-ui');
31
39
 
32
40
  function createSlashCommands({
33
41
  config,
@@ -42,6 +50,8 @@ function createSlashCommands({
42
50
  getOrSpawnForChat,
43
51
  parsePairCodeArgs,
44
52
  modelVersionsDesc,
53
+ resolveRuntimeView = null,
54
+ intentLock,
45
55
  saveConfig = () => {},
46
56
  botName,
47
57
  logEvent,
@@ -61,6 +71,30 @@ function createSlashCommands({
61
71
  // entry.query.getContextUsage directly, which silently said "No
62
72
  // active session yet" on tmux even when the chat was alive.
63
73
  if (botAllowsCommands && text === '/context') {
74
+ if (typeof resolveRuntimeView === 'function') {
75
+ try {
76
+ const runtimeView = await resolveRuntimeView({
77
+ sessionKey,
78
+ chatId,
79
+ threadId: threadIdStr,
80
+ });
81
+ if (isCodexRuntimeView(runtimeView)) {
82
+ await sendReply(
83
+ 'Codex context usage reporting is not supported yet; the active Codex thread was unchanged.',
84
+ );
85
+ return true;
86
+ }
87
+ } catch (error) {
88
+ logger.error?.(
89
+ `[${label}] /context runtime view failed: `
90
+ + `${error?.code || error?.name || 'unknown'}`,
91
+ );
92
+ await sendReply(
93
+ 'Context status is temporarily unavailable; the active session was not changed.',
94
+ );
95
+ return true;
96
+ }
97
+ }
64
98
  if (!pm.has(sessionKey)) {
65
99
  await sendReply('📚 No active session yet — send a message first, then /context.');
66
100
  return true;
@@ -85,6 +119,31 @@ function createSlashCommands({
85
119
  // was LRU-evicted but DB has a saved session_id, auto-spawn
86
120
  // with --resume so /compact has something to work with.
87
121
  if (botAllowsCommands && text.startsWith('/compact')) {
122
+ if (typeof resolveRuntimeView === 'function') {
123
+ let runtimeView;
124
+ try {
125
+ runtimeView = await resolveRuntimeView({
126
+ sessionKey,
127
+ chatId,
128
+ threadId: threadIdStr,
129
+ });
130
+ } catch (error) {
131
+ logger.error?.(
132
+ `[${label}] /compact runtime view failed: `
133
+ + `${error?.code || error?.name || 'unknown'}`,
134
+ );
135
+ await sendReply(
136
+ 'Compaction status is temporarily unavailable; the active session was not changed.',
137
+ );
138
+ return true;
139
+ }
140
+ if (isCodexRuntimeView(runtimeView)) {
141
+ await sendReply(
142
+ 'Codex thread compaction is not supported yet; the active Codex thread was unchanged.',
143
+ );
144
+ return true;
145
+ }
146
+ }
88
147
  let entry = pm.get(sessionKey);
89
148
  if (!entry) {
90
149
  const savedSessionId = getClaudeSessionId(db, sessionKey);
@@ -145,33 +204,96 @@ function createSlashCommands({
145
204
  // /reload → kill closes Query, session_id preserved → same
146
205
  // conversation continues with fresh agent/skill code
147
206
  if (botAllowsCommands && text === '/reload') {
207
+ let runtimeView = null;
208
+ if (typeof resolveRuntimeView === 'function') {
209
+ try {
210
+ runtimeView = await resolveRuntimeView({
211
+ sessionKey,
212
+ chatId,
213
+ threadId: threadIdStr,
214
+ });
215
+ } catch (error) {
216
+ logger.error?.(
217
+ `[${label}] /reload runtime view failed: `
218
+ + `${error?.code || error?.name || 'unknown'}`,
219
+ );
220
+ await sendReply(
221
+ 'Runtime status is temporarily unavailable; the active session was not changed.',
222
+ );
223
+ return true;
224
+ }
225
+ }
226
+ const codex = isCodexRuntimeView(runtimeView);
148
227
  if (pm.has(sessionKey)) {
149
- try { await pm.kill(sessionKey); }
150
- catch (err) { logger.error?.(`[${label}] kill on /reload: ${err.message}`); }
228
+ try {
229
+ await pm.kill(sessionKey);
230
+ } catch (err) {
231
+ logger.error?.(`[${label}] kill on /reload: ${err.message}`);
232
+ await sendReply(
233
+ codex
234
+ ? '🔄 Couldn’t reload Codex because cleanup was not proven; the active Codex thread may be unchanged.'
235
+ : '🔄 Couldn’t reload Claude; the active session may be unchanged.',
236
+ );
237
+ return true;
238
+ }
151
239
  }
152
240
  logEvent('session-reload-command', {
153
241
  chat_id: chatId, command: text,
154
242
  user: cmdUser, user_id: cmdUserId,
155
243
  });
156
- await sendReply('🔄 Reloaded. Next message picks up the conversation with fresh skills/agents.');
244
+ await sendReply(
245
+ codex
246
+ ? '🔄 Reloaded Codex. The next message resumes the same Codex thread with a fresh runtime.'
247
+ : '🔄 Reloaded. Next message picks up the conversation with fresh skills/agents.',
248
+ );
157
249
  return true;
158
250
  }
159
251
 
160
252
  // /new + /reset + /clear — fresh session (all synonyms)
161
253
  if (botAllowsCommands && (text === '/new' || text === '/reset' || text === '/clear')) {
254
+ let runtimeView = null;
255
+ if (typeof resolveRuntimeView === 'function') {
256
+ try {
257
+ runtimeView = await resolveRuntimeView({
258
+ sessionKey,
259
+ chatId,
260
+ threadId: threadIdStr,
261
+ });
262
+ } catch (error) {
263
+ logger.error?.(
264
+ `[${label}] ${text} runtime view failed: `
265
+ + `${error?.code || error?.name || 'unknown'}`,
266
+ );
267
+ await sendReply(
268
+ 'Runtime status is temporarily unavailable; the active session was not changed.',
269
+ );
270
+ return true;
271
+ }
272
+ }
273
+ const codex = isCodexRuntimeView(runtimeView);
162
274
  let drained = 0;
163
275
  try {
164
276
  const r = await pm.resetSession(sessionKey, { reason: text.slice(1) });
165
277
  drained = r?.drainedPendings ?? 0;
166
278
  } catch (err) {
167
279
  logger.error?.(`[${label}] resetSession ${text}: ${err.message}`);
280
+ await sendReply(
281
+ codex
282
+ ? '✨ Couldn’t start a fresh Codex thread because cleanup was not proven; the prior Codex thread may be unchanged.'
283
+ : '✨ Couldn’t start a fresh Claude session; the prior session may be unchanged.',
284
+ );
285
+ return true;
168
286
  }
169
287
  contextHintShown.delete(sessionKey);
170
288
  logEvent('session-reset-command', {
171
289
  chat_id: chatId, command: text, drained_pendings: drained,
172
290
  user: cmdUser, user_id: cmdUserId,
173
291
  });
174
- await sendReply('✨ Started a fresh session.');
292
+ await sendReply(
293
+ codex
294
+ ? '✨ Started a fresh Codex thread.'
295
+ : '✨ Started a fresh session.',
296
+ );
175
297
  return true;
176
298
  }
177
299
 
@@ -216,47 +338,331 @@ function createSlashCommands({
216
338
  // /model X
217
339
  if (botAllowsCommands && text.startsWith('/model ')) {
218
340
  const newModel = text.slice(7).trim();
219
- if (['opus', 'sonnet', 'haiku'].includes(newModel)) {
220
- // Write to the topic when in one (so Music ≠ General) and persist to
221
- // config.json so it survives restarts — both were missing (2026-06-12).
222
- const { scope: wScope, threadId: wThread } = getConfigWriteScope(chatConfig, threadIdStr);
223
- const oldModel = wScope.model != null ? wScope.model : chatConfig.model;
224
- wScope.model = newModel;
225
- try { saveConfig(); }
226
- catch (err) { logger.error?.(`[${botName}] /model saveConfig failed: ${err.message}`); }
227
- dbWrite(() => db.logConfigChange({
228
- chat_id: chatId, thread_id: wThread, field: 'model',
229
- old_value: oldModel, new_value: newModel,
230
- user: cmdUser, user_id: cmdUserId, source: 'command',
231
- }), 'log model change');
232
- const { anyActive } = await applyConfigChange('model', newModel);
233
- const ver = (modelVersionsDesc && modelVersionsDesc[newModel]) || newModel;
234
- await sendReply(`Model → ${newModel} (${ver})${cliAwareSuffix(anyActive)}`);
235
- } else {
236
- await sendReply(`Unknown model. Use: opus, sonnet, haiku`);
341
+ const releaseIntent = await intentLock.acquire(sessionKey);
342
+ let replyText;
343
+ try {
344
+ let runtimeView = null;
345
+ if (typeof resolveRuntimeView === 'function') {
346
+ try {
347
+ runtimeView = await resolveRuntimeView({
348
+ sessionKey,
349
+ chatId,
350
+ threadId: threadIdStr,
351
+ });
352
+ } catch (error) {
353
+ logger.error?.(
354
+ `[${label}] /model runtime view failed: `
355
+ + `${error?.code || error?.name || 'unknown'}`,
356
+ );
357
+ replyText = 'Model settings are temporarily unavailable.';
358
+ }
359
+ }
360
+ if (!replyText) {
361
+ const codex = isCodexRuntimeView(runtimeView);
362
+ const validModels = codex
363
+ ? getCodexCatalogModels(runtimeView).map((entry) => entry.model)
364
+ : ['opus', 'sonnet', 'haiku'];
365
+ if (!validModels.includes(newModel)) {
366
+ replyText =
367
+ `Unknown model. Use: ${validModels.join(', ')
368
+ || 'no authenticated models available'}`;
369
+ } else {
370
+ const { scope: wScope, threadId: wThread } =
371
+ getConfigWriteScope(chatConfig, threadIdStr);
372
+ const configField = codex ? 'codexModel' : 'model';
373
+ const oldModel = wScope[configField] != null
374
+ ? wScope[configField]
375
+ : chatConfig[configField] != null
376
+ ? chatConfig[configField]
377
+ : codex ? runtimeView.model : chatConfig[configField];
378
+ const snapshots = new Map([
379
+ [configField, {
380
+ present: Object.hasOwn(wScope, configField),
381
+ value: wScope[configField],
382
+ }],
383
+ ['codexEffort', {
384
+ present: Object.hasOwn(wScope, 'codexEffort'),
385
+ value: wScope.codexEffort,
386
+ }],
387
+ ]);
388
+ let effortAdjustment = null;
389
+ let nextEffort = null;
390
+ if (codex) {
391
+ const oldEffort = wScope.codexEffort != null
392
+ ? wScope.codexEffort
393
+ : chatConfig.codexEffort != null
394
+ ? chatConfig.codexEffort
395
+ : runtimeView.effort;
396
+ nextEffort = resolveCodexEffortForModel(
397
+ runtimeView,
398
+ newModel,
399
+ oldEffort,
400
+ );
401
+ if (nextEffort == null) {
402
+ replyText =
403
+ 'Selected model has no authenticated default effort.';
404
+ } else if (nextEffort !== oldEffort) {
405
+ effortAdjustment = {
406
+ oldValue: oldEffort,
407
+ newValue: nextEffort,
408
+ };
409
+ }
410
+ }
411
+ if (!replyText) {
412
+ wScope[configField] = newModel;
413
+ if (effortAdjustment) {
414
+ wScope.codexEffort = effortAdjustment.newValue;
415
+ }
416
+ try {
417
+ await saveConfig();
418
+ } catch (error) {
419
+ for (const [field, snapshot] of snapshots) {
420
+ if (snapshot.present) wScope[field] = snapshot.value;
421
+ else delete wScope[field];
422
+ }
423
+ logger.error?.(
424
+ `[${botName}] /model saveConfig failed: ${error.message}`,
425
+ );
426
+ replyText =
427
+ "Couldn't save the model selection; nothing changed.";
428
+ }
429
+ }
430
+ if (!replyText) {
431
+ const auditRows = [{
432
+ chat_id: chatId, thread_id: wThread, field: 'model',
433
+ old_value: oldModel, new_value: newModel,
434
+ user: cmdUser, user_id: cmdUserId, source: 'command',
435
+ }];
436
+ if (effortAdjustment) {
437
+ auditRows.push({
438
+ chat_id: chatId,
439
+ thread_id: wThread,
440
+ field: 'effort',
441
+ old_value: effortAdjustment.oldValue,
442
+ new_value: effortAdjustment.newValue,
443
+ user: cmdUser,
444
+ user_id: cmdUserId,
445
+ source: 'command',
446
+ });
447
+ }
448
+ if (codex) {
449
+ try {
450
+ db.logConfigChanges(auditRows);
451
+ } catch (error) {
452
+ for (const [field, snapshot] of snapshots) {
453
+ if (snapshot.present) wScope[field] = snapshot.value;
454
+ else delete wScope[field];
455
+ }
456
+ let rollbackFailed = false;
457
+ try {
458
+ await saveConfig();
459
+ } catch (rollbackError) {
460
+ rollbackFailed = true;
461
+ logger.error?.(
462
+ `[${botName}] /model config rollback failed: `
463
+ + rollbackError.message,
464
+ );
465
+ }
466
+ logger.error?.(
467
+ `[${botName}] /model audit failed: ${error.message}`,
468
+ );
469
+ replyText = rollbackFailed
470
+ ? "Couldn't audit the model selection; the live process "
471
+ + 'was unchanged, but persisted config needs attention.'
472
+ : "Couldn't audit the model selection; nothing changed.";
473
+ }
474
+ } else {
475
+ dbWrite(() => {
476
+ for (const row of auditRows) db.logConfigChange(row);
477
+ }, 'log model change');
478
+ }
479
+ }
480
+ if (!replyText) {
481
+ let settingsResult = null;
482
+ let anyActive = false;
483
+ if (codex) {
484
+ try {
485
+ settingsResult = await pm.selectModelSettings(sessionKey, {
486
+ model: newModel,
487
+ effort: nextEffort,
488
+ });
489
+ } catch (error) {
490
+ logger.error?.(
491
+ `[${label}] Codex /model live selection failed: `
492
+ + `${error?.code || error?.name || 'unknown'}`,
493
+ );
494
+ settingsResult = {
495
+ outcome: 'live-status-unknown',
496
+ nextTurn: { model: newModel, effort: nextEffort },
497
+ };
498
+ }
499
+ } else {
500
+ ({ anyActive } = await applyConfigChange('model', newModel));
501
+ }
502
+ const catalogEntry = codex
503
+ ? getCodexCatalogModels(runtimeView)
504
+ .find((entry) => entry.model === newModel)
505
+ : null;
506
+ const ver = codex
507
+ ? catalogEntry?.displayName || newModel
508
+ : (modelVersionsDesc && modelVersionsDesc[newModel])
509
+ || newModel;
510
+ const suffix = codex
511
+ ? formatCodexSettingsOutcome(settingsResult)
512
+ : cliAwareSuffix(anyActive);
513
+ const effortNote = effortAdjustment
514
+ ? `; Effort → ${effortAdjustment.newValue}`
515
+ : '';
516
+ replyText =
517
+ `Model → ${newModel} (${ver})${effortNote}${suffix}`;
518
+ }
519
+ }
520
+ }
521
+ } finally {
522
+ releaseIntent();
237
523
  }
524
+ await sendReply(replyText);
238
525
  return true;
239
526
  }
240
527
 
241
528
  // /effort X
242
529
  if (botAllowsCommands && text.startsWith('/effort ')) {
243
530
  const newEffort = text.slice(8).trim();
244
- if (['low', 'medium', 'high', 'xhigh', 'max'].includes(newEffort)) {
245
- const { scope: wScope, threadId: wThread } = getConfigWriteScope(chatConfig, threadIdStr);
246
- const oldEffort = wScope.effort != null ? wScope.effort : chatConfig.effort;
247
- wScope.effort = newEffort;
248
- try { saveConfig(); }
249
- catch (err) { logger.error?.(`[${botName}] /effort saveConfig failed: ${err.message}`); }
250
- dbWrite(() => db.logConfigChange({
251
- chat_id: chatId, thread_id: wThread, field: 'effort',
252
- old_value: oldEffort, new_value: newEffort,
253
- user: cmdUser, user_id: cmdUserId, source: 'command',
254
- }), 'log effort change');
255
- const { anyActive } = await applyConfigChange('effort', newEffort);
256
- await sendReply(`Effort → ${newEffort}${cliAwareSuffix(anyActive)}`);
257
- } else {
258
- await sendReply(`Unknown effort. Use: low, medium, high, xhigh, max`);
531
+ const releaseIntent = await intentLock.acquire(sessionKey);
532
+ let replyText;
533
+ try {
534
+ let runtimeView = null;
535
+ if (typeof resolveRuntimeView === 'function') {
536
+ try {
537
+ runtimeView = await resolveRuntimeView({
538
+ sessionKey,
539
+ chatId,
540
+ threadId: threadIdStr,
541
+ });
542
+ } catch (error) {
543
+ logger.error?.(
544
+ `[${label}] /effort runtime view failed: `
545
+ + `${error?.code || error?.name || 'unknown'}`,
546
+ );
547
+ replyText = 'Effort settings are temporarily unavailable.';
548
+ }
549
+ }
550
+ if (!replyText) {
551
+ const codex = isCodexRuntimeView(runtimeView);
552
+ const topicConfig = threadIdStr
553
+ ? chatConfig?.topics?.[threadIdStr]
554
+ : null;
555
+ const currentModel = configuredRuntimeValue(
556
+ chatConfig,
557
+ topicConfig,
558
+ runtimeView,
559
+ 'model',
560
+ );
561
+ const validEfforts = codex
562
+ ? getCodexCatalogEfforts(runtimeView, currentModel)
563
+ : ['low', 'medium', 'high', 'xhigh', 'max'];
564
+ if (!validEfforts.includes(newEffort)) {
565
+ replyText =
566
+ `Unknown effort. Use: ${validEfforts.join(', ')
567
+ || 'no authenticated efforts available'}`;
568
+ } else {
569
+ const { scope: wScope, threadId: wThread } =
570
+ getConfigWriteScope(chatConfig, threadIdStr);
571
+ const configField = codex ? 'codexEffort' : 'effort';
572
+ const oldEffort = wScope[configField] != null
573
+ ? wScope[configField]
574
+ : chatConfig[configField] != null
575
+ ? chatConfig[configField]
576
+ : codex ? runtimeView.effort : chatConfig[configField];
577
+ const snapshot = {
578
+ present: Object.hasOwn(wScope, configField),
579
+ value: wScope[configField],
580
+ };
581
+ wScope[configField] = newEffort;
582
+ try {
583
+ await saveConfig();
584
+ } catch (error) {
585
+ if (snapshot.present) wScope[configField] = snapshot.value;
586
+ else delete wScope[configField];
587
+ logger.error?.(
588
+ `[${botName}] /effort saveConfig failed: ${error.message}`,
589
+ );
590
+ replyText =
591
+ "Couldn't save the effort selection; nothing changed.";
592
+ }
593
+ if (!replyText) {
594
+ const auditRow = {
595
+ chat_id: chatId, thread_id: wThread, field: 'effort',
596
+ old_value: oldEffort, new_value: newEffort,
597
+ user: cmdUser, user_id: cmdUserId, source: 'command',
598
+ };
599
+ if (codex) {
600
+ try {
601
+ db.logConfigChanges([auditRow]);
602
+ } catch (error) {
603
+ if (snapshot.present) wScope[configField] = snapshot.value;
604
+ else delete wScope[configField];
605
+ let rollbackFailed = false;
606
+ try {
607
+ await saveConfig();
608
+ } catch (rollbackError) {
609
+ rollbackFailed = true;
610
+ logger.error?.(
611
+ `[${botName}] /effort config rollback failed: `
612
+ + rollbackError.message,
613
+ );
614
+ }
615
+ logger.error?.(
616
+ `[${botName}] /effort audit failed: ${error.message}`,
617
+ );
618
+ replyText = rollbackFailed
619
+ ? "Couldn't audit the effort selection; the live process "
620
+ + 'was unchanged, but persisted config needs attention.'
621
+ : "Couldn't audit the effort selection; nothing changed.";
622
+ }
623
+ } else {
624
+ dbWrite(
625
+ () => db.logConfigChange(auditRow),
626
+ 'log effort change',
627
+ );
628
+ }
629
+ }
630
+ if (!replyText) {
631
+ let settingsResult = null;
632
+ let anyActive = false;
633
+ if (codex) {
634
+ try {
635
+ settingsResult = await pm.selectModelSettings(sessionKey, {
636
+ model: currentModel,
637
+ effort: newEffort,
638
+ });
639
+ } catch (error) {
640
+ logger.error?.(
641
+ `[${label}] Codex /effort live selection failed: `
642
+ + `${error?.code || error?.name || 'unknown'}`,
643
+ );
644
+ settingsResult = {
645
+ outcome: 'live-status-unknown',
646
+ nextTurn: { model: currentModel, effort: newEffort },
647
+ };
648
+ }
649
+ } else {
650
+ ({ anyActive } = await applyConfigChange(
651
+ 'effort',
652
+ newEffort,
653
+ ));
654
+ }
655
+ const suffix = codex
656
+ ? formatCodexSettingsOutcome(settingsResult)
657
+ : cliAwareSuffix(anyActive);
658
+ replyText = `Effort → ${newEffort}${suffix}`;
659
+ }
660
+ }
661
+ }
662
+ } finally {
663
+ releaseIntent();
259
664
  }
665
+ await sendReply(replyText);
260
666
  return true;
261
667
  }
262
668
 
package/lib/ipc/client.js CHANGED
@@ -8,27 +8,29 @@
8
8
 
9
9
  const net = require('net');
10
10
  const fs = require('fs');
11
+ const {
12
+ runtimeDirectory,
13
+ ensureRuntimeDirectory,
14
+ socketPathFor,
15
+ secretPathFor,
16
+ validateBotName,
17
+ } = require('./runtime-directory');
11
18
 
12
19
  const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
13
20
  const DEFAULT_CALL_TIMEOUT_MS = 5 * 60 * 1000;
14
21
 
15
- function socketPathFor(botName) {
16
- return `/tmp/polygram-${botName}.sock`;
17
- }
18
-
19
- function secretPathFor(botName) {
20
- return `/tmp/polygram-${botName}.secret`;
21
- }
22
-
23
22
  /**
24
23
  * Read the IPC secret for a bot. Prefers POLYGRAM_IPC_SECRET env var (set by
25
24
  * polygram when spawning Claude subprocesses) over the file (used by
26
25
  * cron and external callers that aren't polygram children). Missing secret
27
26
  * is not an error — caller decides whether to send it.
28
27
  */
29
- function readSecret(botName) {
30
- if (process.env.POLYGRAM_IPC_SECRET) return process.env.POLYGRAM_IPC_SECRET;
31
- try { return fs.readFileSync(secretPathFor(botName), 'utf8').trim(); }
28
+ function readSecret(botName, options) {
29
+ validateBotName(botName);
30
+ const env = options?.env || process.env;
31
+ if (env.POLYGRAM_IPC_SECRET) return env.POLYGRAM_IPC_SECRET;
32
+ const secretPath = secretPathFor(botName, options);
33
+ try { return fs.readFileSync(secretPath, 'utf8').trim(); }
32
34
  catch { return null; }
33
35
  }
34
36
 
@@ -111,4 +113,12 @@ async function tell(bot, method, params = {}, opts = {}) {
111
113
  return res.result;
112
114
  }
113
115
 
114
- module.exports = { call, tell, socketPathFor, secretPathFor, readSecret };
116
+ module.exports = {
117
+ call,
118
+ tell,
119
+ runtimeDirectory,
120
+ ensureRuntimeDirectory,
121
+ socketPathFor,
122
+ secretPathFor,
123
+ readSecret,
124
+ };