@provos/ironcurtain 0.6.0 → 0.7.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 (187) hide show
  1. package/README.md +21 -2
  2. package/dist/cli-help.d.ts +63 -0
  3. package/dist/cli-help.js +102 -0
  4. package/dist/cli-help.js.map +1 -0
  5. package/dist/cli.js +73 -56
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config/config-command.js +136 -2
  8. package/dist/config/config-command.js.map +1 -1
  9. package/dist/config/generated/tool-annotations.json +487 -338
  10. package/dist/config/index.d.ts +21 -1
  11. package/dist/config/index.js +12 -4
  12. package/dist/config/index.js.map +1 -1
  13. package/dist/config/mcp-servers.json +2 -8
  14. package/dist/config/paths.d.ts +30 -0
  15. package/dist/config/paths.js +58 -0
  16. package/dist/config/paths.js.map +1 -1
  17. package/dist/config/tool-description-hints.json +15 -0
  18. package/dist/config/types.d.ts +7 -0
  19. package/dist/config/user-config.d.ts +21 -0
  20. package/dist/config/user-config.js +8 -0
  21. package/dist/config/user-config.js.map +1 -1
  22. package/dist/cron/compile-task-policy.d.ts +17 -0
  23. package/dist/cron/compile-task-policy.js +39 -0
  24. package/dist/cron/compile-task-policy.js.map +1 -0
  25. package/dist/cron/cron-scheduler.d.ts +65 -0
  26. package/dist/cron/cron-scheduler.js +184 -0
  27. package/dist/cron/cron-scheduler.js.map +1 -0
  28. package/dist/cron/file-lock.d.ts +41 -0
  29. package/dist/cron/file-lock.js +156 -0
  30. package/dist/cron/file-lock.js.map +1 -0
  31. package/dist/cron/format-utils.d.ts +18 -0
  32. package/dist/cron/format-utils.js +145 -0
  33. package/dist/cron/format-utils.js.map +1 -0
  34. package/dist/cron/git-sync.d.ts +22 -0
  35. package/dist/cron/git-sync.js +121 -0
  36. package/dist/cron/git-sync.js.map +1 -0
  37. package/dist/cron/headless-transport.d.ts +27 -0
  38. package/dist/cron/headless-transport.js +43 -0
  39. package/dist/cron/headless-transport.js.map +1 -0
  40. package/dist/cron/job-commands.d.ts +39 -0
  41. package/dist/cron/job-commands.js +522 -0
  42. package/dist/cron/job-commands.js.map +1 -0
  43. package/dist/cron/job-store.d.ts +35 -0
  44. package/dist/cron/job-store.js +111 -0
  45. package/dist/cron/job-store.js.map +1 -0
  46. package/dist/cron/types.d.ts +127 -0
  47. package/dist/cron/types.js +25 -0
  48. package/dist/cron/types.js.map +1 -0
  49. package/dist/daemon/control-socket.d.ts +127 -0
  50. package/dist/daemon/control-socket.js +261 -0
  51. package/dist/daemon/control-socket.js.map +1 -0
  52. package/dist/daemon/daemon-command.d.ts +13 -0
  53. package/dist/daemon/daemon-command.js +230 -0
  54. package/dist/daemon/daemon-command.js.map +1 -0
  55. package/dist/daemon/ironcurtain-daemon.d.ts +90 -0
  56. package/dist/daemon/ironcurtain-daemon.js +548 -0
  57. package/dist/daemon/ironcurtain-daemon.js.map +1 -0
  58. package/dist/docker/adapters/claude-code.js +7 -47
  59. package/dist/docker/adapters/claude-code.js.map +1 -1
  60. package/dist/docker/adapters/goose.d.ts +58 -0
  61. package/dist/docker/adapters/goose.js +250 -0
  62. package/dist/docker/adapters/goose.js.map +1 -0
  63. package/dist/docker/adapters/shared-scripts.d.ts +29 -0
  64. package/dist/docker/adapters/shared-scripts.js +80 -0
  65. package/dist/docker/adapters/shared-scripts.js.map +1 -0
  66. package/dist/docker/agent-adapter.d.ts +12 -0
  67. package/dist/docker/agent-adapter.js.map +1 -1
  68. package/dist/docker/agent-registry.d.ts +6 -1
  69. package/dist/docker/agent-registry.js +10 -1
  70. package/dist/docker/agent-registry.js.map +1 -1
  71. package/dist/docker/docker-agent-session.d.ts +2 -25
  72. package/dist/docker/docker-agent-session.js +7 -106
  73. package/dist/docker/docker-agent-session.js.map +1 -1
  74. package/dist/docker/docker-infrastructure.d.ts +8 -0
  75. package/dist/docker/docker-infrastructure.js +12 -11
  76. package/dist/docker/docker-infrastructure.js.map +1 -1
  77. package/dist/docker/pty-session.js +7 -4
  78. package/dist/docker/pty-session.js.map +1 -1
  79. package/dist/escalation/escalation-watcher.d.ts +2 -0
  80. package/dist/escalation/escalation-watcher.js +3 -0
  81. package/dist/escalation/escalation-watcher.js.map +1 -1
  82. package/dist/escalation/session-registry.js +1 -14
  83. package/dist/escalation/session-registry.js.map +1 -1
  84. package/dist/index.js +30 -1
  85. package/dist/index.js.map +1 -1
  86. package/dist/mux/mux-app.js +11 -6
  87. package/dist/mux/mux-app.js.map +1 -1
  88. package/dist/mux/mux-command.js +20 -9
  89. package/dist/mux/mux-command.js.map +1 -1
  90. package/dist/mux/mux-input-handler.d.ts +5 -0
  91. package/dist/mux/mux-input-handler.js +64 -9
  92. package/dist/mux/mux-input-handler.js.map +1 -1
  93. package/dist/mux/mux-renderer.d.ts +20 -0
  94. package/dist/mux/mux-renderer.js +127 -35
  95. package/dist/mux/mux-renderer.js.map +1 -1
  96. package/dist/mux/mux-splash.js +2 -1
  97. package/dist/mux/mux-splash.js.map +1 -1
  98. package/dist/mux/paste-interceptor.d.ts +22 -0
  99. package/dist/mux/paste-interceptor.js +143 -0
  100. package/dist/mux/paste-interceptor.js.map +1 -0
  101. package/dist/mux/pty-bridge.js +1 -1
  102. package/dist/mux/pty-bridge.js.map +1 -1
  103. package/dist/mux/types.d.ts +8 -1
  104. package/dist/mux/types.js +17 -5
  105. package/dist/mux/types.js.map +1 -1
  106. package/dist/pipeline/annotate.js +2 -1
  107. package/dist/pipeline/annotate.js.map +1 -1
  108. package/dist/pipeline/compile.d.ts +4 -34
  109. package/dist/pipeline/compile.js +29 -568
  110. package/dist/pipeline/compile.js.map +1 -1
  111. package/dist/pipeline/constitution-compiler.js +1 -1
  112. package/dist/pipeline/mcp-connections.d.ts +16 -0
  113. package/dist/pipeline/mcp-connections.js +61 -0
  114. package/dist/pipeline/mcp-connections.js.map +1 -0
  115. package/dist/pipeline/pipeline-runner.d.ts +86 -0
  116. package/dist/pipeline/pipeline-runner.js +554 -0
  117. package/dist/pipeline/pipeline-runner.js.map +1 -0
  118. package/dist/pipeline/pipeline-shared.d.ts +29 -2
  119. package/dist/pipeline/pipeline-shared.js +68 -12
  120. package/dist/pipeline/pipeline-shared.js.map +1 -1
  121. package/dist/pipeline/refresh-lists.js +38 -12
  122. package/dist/pipeline/refresh-lists.js.map +1 -1
  123. package/dist/pipeline/scenario-generator.js +2 -1
  124. package/dist/pipeline/scenario-generator.js.map +1 -1
  125. package/dist/pipeline/tool-annotator.d.ts +3 -3
  126. package/dist/pipeline/tool-annotator.js +96 -4
  127. package/dist/pipeline/tool-annotator.js.map +1 -1
  128. package/dist/pipeline/types.d.ts +52 -0
  129. package/dist/sandbox/index.js +36 -1
  130. package/dist/sandbox/index.js.map +1 -1
  131. package/dist/session/agent-session.d.ts +3 -0
  132. package/dist/session/agent-session.js +10 -1
  133. package/dist/session/agent-session.js.map +1 -1
  134. package/dist/session/index.js +54 -5
  135. package/dist/session/index.js.map +1 -1
  136. package/dist/session/preflight.js +26 -6
  137. package/dist/session/preflight.js.map +1 -1
  138. package/dist/session/prompts.d.ts +14 -0
  139. package/dist/session/prompts.js +39 -2
  140. package/dist/session/prompts.js.map +1 -1
  141. package/dist/session/session-manager.d.ts +105 -0
  142. package/dist/session/session-manager.js +134 -0
  143. package/dist/session/session-manager.js.map +1 -0
  144. package/dist/session/types.d.ts +27 -0
  145. package/dist/session/workspace-validation.d.ts +2 -0
  146. package/dist/session/workspace-validation.js +6 -5
  147. package/dist/session/workspace-validation.js.map +1 -1
  148. package/dist/signal/bot-command.d.ts +2 -0
  149. package/dist/signal/bot-command.js +8 -0
  150. package/dist/signal/bot-command.js.map +1 -1
  151. package/dist/signal/format.d.ts +27 -0
  152. package/dist/signal/format.js +59 -0
  153. package/dist/signal/format.js.map +1 -1
  154. package/dist/signal/signal-bot-daemon.d.ts +27 -25
  155. package/dist/signal/signal-bot-daemon.js +145 -130
  156. package/dist/signal/signal-bot-daemon.js.map +1 -1
  157. package/dist/signal/signal-container.js +63 -19
  158. package/dist/signal/signal-container.js.map +1 -1
  159. package/dist/signal/signal-transport.js +3 -8
  160. package/dist/signal/signal-transport.js.map +1 -1
  161. package/dist/trusted-process/domain-utils.d.ts +38 -4
  162. package/dist/trusted-process/domain-utils.js +118 -11
  163. package/dist/trusted-process/domain-utils.js.map +1 -1
  164. package/dist/trusted-process/index.js +6 -2
  165. package/dist/trusted-process/index.js.map +1 -1
  166. package/dist/trusted-process/mcp-client-manager.js +11 -5
  167. package/dist/trusted-process/mcp-client-manager.js.map +1 -1
  168. package/dist/trusted-process/mcp-proxy-server.d.ts +2 -0
  169. package/dist/trusted-process/mcp-proxy-server.js +59 -16
  170. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  171. package/dist/trusted-process/permissive-output-validator.d.ts +23 -0
  172. package/dist/trusted-process/permissive-output-validator.js +10 -0
  173. package/dist/trusted-process/permissive-output-validator.js.map +1 -0
  174. package/dist/trusted-process/policy-engine.d.ts +27 -4
  175. package/dist/trusted-process/policy-engine.js +152 -57
  176. package/dist/trusted-process/policy-engine.js.map +1 -1
  177. package/dist/trusted-process/tool-description-hints.d.ts +19 -0
  178. package/dist/trusted-process/tool-description-hints.js +53 -0
  179. package/dist/trusted-process/tool-description-hints.js.map +1 -0
  180. package/dist/types/argument-roles.d.ts +33 -1
  181. package/dist/types/argument-roles.js +129 -1
  182. package/dist/types/argument-roles.js.map +1 -1
  183. package/docker/Dockerfile.goose +34 -0
  184. package/docker/entrypoint-goose.sh +30 -0
  185. package/package.json +1 -1
  186. package/src/config/generated/tool-annotations.json +487 -338
  187. package/src/config/mcp-servers.json +2 -8
@@ -9,14 +9,19 @@
9
9
  * - Identity verification with TTL cache (5 min), fail-closed
10
10
  * - Escalation state with race prevention (per-session)
11
11
  * - Message sending via POST /v2/send with text_mode: "styled"
12
+ *
13
+ * Session state is owned by SessionManager (injected via constructor).
14
+ * This class only handles Signal-specific concerns: WebSocket, identity,
15
+ * message parsing, and command dispatch.
12
16
  */
13
17
  import { createSession } from '../session/index.js';
14
18
  import { loadConfig } from '../config/index.js';
15
19
  import { loadUserConfig } from '../config/user-config.js';
16
20
  import { resolveSignalConfig } from './signal-config.js';
21
+ import { SessionManager } from '../session/session-manager.js';
17
22
  import { SignalSessionTransport } from './signal-transport.js';
18
23
  import { markdownToSignal } from './markdown-to-signal.js';
19
- import { formatBudgetMessage, formatBudgetSummary, formatSessionList, prefixWithLabel, splitMessage, SIGNAL_MAX_MESSAGE_LENGTH, } from './format.js';
24
+ import { formatBudgetMessage, formatBudgetSummary, formatJobList, formatSessionList, prefixWithLabel, splitMessage, SIGNAL_MAX_MESSAGE_LENGTH, } from './format.js';
20
25
  import { BudgetExhaustedError } from '../session/errors.js';
21
26
  import * as logger from '../logger.js';
22
27
  /** How often to proactively verify the recipient's identity key (ms). */
@@ -25,6 +30,7 @@ export class SignalBotDaemon {
25
30
  config;
26
31
  containerManager;
27
32
  mode;
33
+ sessionManager;
28
34
  // WebSocket state
29
35
  ws = null;
30
36
  baseUrl = '';
@@ -32,10 +38,6 @@ export class SignalBotDaemon {
32
38
  reconnectAttempts = 0;
33
39
  static MAX_RECONNECT_DELAY_MS = 30_000;
34
40
  static BASE_RECONNECT_DELAY_MS = 1_000;
35
- // Multi-session state
36
- sessions = new Map();
37
- currentLabel = null;
38
- nextLabel = 1;
39
41
  maxConcurrentSessions;
40
42
  // Identity verification state
41
43
  identityLocked = false;
@@ -54,6 +56,7 @@ export class SignalBotDaemon {
54
56
  this.containerManager = options.containerManager;
55
57
  this.mode = options.mode;
56
58
  this.maxConcurrentSessions = options.config.maxConcurrentSessions;
59
+ this.sessionManager = options.sessionManager ?? new SessionManager();
57
60
  }
58
61
  /**
59
62
  * Schedules a session-mutating operation (/new, /quit) so that
@@ -66,21 +69,45 @@ export class SignalBotDaemon {
66
69
  });
67
70
  }
68
71
  /**
69
- * Starts the daemon. Returns a promise that resolves when
70
- * shutdown() is called (e.g., SIGTERM/SIGINT).
72
+ * Connects the daemon: starts the Docker container, waits for
73
+ * health, connects WebSocket, and sends the online greeting.
74
+ * Throws if any step fails (container won't start, health timeout, etc.).
71
75
  */
72
- async start() {
76
+ async connect() {
77
+ logger.info('[Signal Daemon] Ensuring container is running...');
73
78
  this.baseUrl = await this.containerManager.ensureRunning();
79
+ logger.info('[Signal Daemon] Waiting for container health check...');
74
80
  await this.containerManager.waitForHealthy(this.baseUrl);
81
+ logger.info('[Signal Daemon] Connecting WebSocket...');
75
82
  await this.connectWebSocket();
76
- await this.sendSignalMessage('IronCurtain bot is online. Send a message to begin.');
77
- // Block until shutdown
83
+ // Greeting is best-effort signal-cli may be healthy but not yet
84
+ // fully connected to Signal servers (e.g., broken pipe on first send).
85
+ try {
86
+ await this.sendSignalMessage('IronCurtain bot is online. Send a message to begin.');
87
+ }
88
+ catch (err) {
89
+ logger.warn(`[Signal Daemon] Greeting failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
90
+ }
91
+ }
92
+ /**
93
+ * Blocks until shutdown() is called (e.g., SIGTERM/SIGINT).
94
+ * Call connect() first to establish the connection.
95
+ */
96
+ async run() {
78
97
  await new Promise((resolve) => {
79
98
  this.exitResolve = resolve;
80
99
  });
81
100
  }
82
101
  /**
83
- * Initiates graceful shutdown. Ends all active sessions,
102
+ * Convenience: connects and then blocks until shutdown.
103
+ * Used by the standalone `ironcurtain bot` command.
104
+ */
105
+ async start() {
106
+ await this.connect();
107
+ await this.run();
108
+ }
109
+ /**
110
+ * Initiates graceful shutdown. Ends all Signal sessions,
84
111
  * closes the WebSocket, and unblocks start().
85
112
  */
86
113
  async shutdown() {
@@ -89,10 +116,10 @@ export class SignalBotDaemon {
89
116
  await this.sendSignalMessage('IronCurtain bot is shutting down. Goodbye.').catch(() => { });
90
117
  // Wait for any in-flight session operations (e.g., /new) to complete
91
118
  await this.sessionOpInProgress.catch(() => { });
92
- // End all sessions without letting a single failure abort shutdown
93
- const labels = [...this.sessions.keys()];
94
- await Promise.allSettled(labels.map((label) => this.endSession(label).catch((err) => {
95
- logger.error(`[Signal Daemon] Failed to end session #${label} during shutdown: ${String(err)}`);
119
+ // End all Signal sessions without letting a single failure abort shutdown
120
+ const signalSessions = this.sessionManager.byKind('signal');
121
+ await Promise.allSettled(signalSessions.map((managed) => this.sessionManager.end(managed.label).catch((err) => {
122
+ logger.error(`[Signal Daemon] Failed to end session #${managed.label} during shutdown: ${String(err)}`);
96
123
  })));
97
124
  if (this.ws) {
98
125
  this.ws.close();
@@ -197,21 +224,29 @@ export class SignalBotDaemon {
197
224
  if (!text)
198
225
  return;
199
226
  // Escalation replies: approve/deny (also accept /approve, /deny)
227
+ // This works across ALL session types (signal + cron)
200
228
  if (this.handleEscalationReply(text))
201
229
  return;
202
- // Control commands: /quit, /new, /sessions, /switch, /budget, /help
230
+ // Control commands: /quit, /new, /sessions, /switch, /budget, /jobs, /help
203
231
  if (this.handleControlCommand(text))
204
232
  return;
205
- // Regular message -> route to session
233
+ // Reject unrecognized slash commands rather than forwarding to agent
234
+ if (text.trimStart().startsWith('/')) {
235
+ const cmd = text.trim().split(/\s/)[0];
236
+ await this.sendSignalMessage(`Unknown command: ${cmd}\nSend /help for available commands.`);
237
+ return;
238
+ }
239
+ // Regular message -> route to Signal session
206
240
  await this.routeToSession(text);
207
241
  }
208
242
  /**
209
- * Routes a user message to the current session. Creates a new
243
+ * Routes a user message to the current Signal session. Creates a new
210
244
  * session if none exists. Handles BudgetExhaustedError by ending
211
245
  * the exhausted session and notifying the user.
212
246
  *
213
247
  * Supports one-shot `#N` prefix routing: `#2 do something` routes
214
248
  * "do something" to session #2 without changing currentLabel.
249
+ * Only Signal sessions accept forwarded messages.
215
250
  */
216
251
  async routeToSession(text) {
217
252
  // Wait for any pending session operation (/new, /quit) to complete
@@ -221,23 +256,27 @@ export class SignalBotDaemon {
221
256
  const { targetLabel, messageText } = parseHashPrefix(text);
222
257
  // If #N prefix was used, route directly to that session (no auto-create)
223
258
  if (targetLabel !== null) {
224
- const managed = this.sessions.get(targetLabel);
259
+ const managed = this.sessionManager.get(targetLabel);
225
260
  if (!managed) {
226
261
  await this.sendSignalMessage(`No session #${targetLabel}.`);
227
262
  return;
228
263
  }
264
+ if (managed.source.kind !== 'signal') {
265
+ await this.sendSignalMessage(`Session #${targetLabel} is a ${managed.source.kind} session and does not accept messages.`);
266
+ return;
267
+ }
229
268
  await this.forwardToSession(managed, messageText);
230
269
  return;
231
270
  }
232
271
  // Create session on demand if none exists.
233
272
  // Serialize through scheduleSessionOp so concurrent messages don't
234
273
  // race to create multiple sessions.
235
- if (this.currentLabel === null) {
274
+ if (this.sessionManager.currentLabel === null) {
236
275
  const createOp = new Promise((resolve, reject) => {
237
276
  this.scheduleSessionOp(async () => {
238
277
  try {
239
278
  // Re-check after serialization — another message may have created one
240
- if (this.currentLabel === null) {
279
+ if (this.sessionManager.currentLabel === null) {
241
280
  await this.startNewSession();
242
281
  }
243
282
  resolve();
@@ -256,9 +295,10 @@ export class SignalBotDaemon {
256
295
  return;
257
296
  }
258
297
  }
259
- if (this.currentLabel === null)
298
+ const currentLabel = this.sessionManager.currentLabel;
299
+ if (currentLabel === null)
260
300
  return;
261
- const managed = this.sessions.get(this.currentLabel);
301
+ const managed = this.sessionManager.get(currentLabel);
262
302
  if (!managed)
263
303
  return;
264
304
  await this.forwardToSession(managed, messageText);
@@ -268,18 +308,23 @@ export class SignalBotDaemon {
268
308
  * in-flight guards, BudgetExhaustedError, and response formatting.
269
309
  */
270
310
  async forwardToSession(managed, text) {
311
+ const transport = managed.transport;
312
+ const sessionCount = this.sessionManager.size;
271
313
  logger.info(`[Signal Daemon] Routing message to session #${managed.label} ` +
272
- `(currentLabel=${this.currentLabel}, sessions=${[...this.sessions.keys()].join(',')}, ` +
273
- `transportLabel=${managed.transport.sessionLabel})`);
314
+ `(currentLabel=${this.sessionManager.currentLabel}, sessions=${this.sessionManager
315
+ .all()
316
+ .map((m) => m.label)
317
+ .join(',')}, ` +
318
+ `transportLabel=${transport.sessionLabel})`);
274
319
  if (managed.messageInFlight) {
275
- await this.sendSignalMessage(prefixWithLabel('Still processing previous message, please wait...', managed.label, this.sessions.size));
320
+ await this.sendSignalMessage(prefixWithLabel('Still processing previous message, please wait...', managed.label, sessionCount));
276
321
  return;
277
322
  }
278
323
  managed.messageInFlight = true;
279
324
  try {
280
- const response = await managed.transport.forwardMessage(text);
325
+ const response = await transport.forwardMessage(text);
281
326
  const styledText = markdownToSignal(response);
282
- await this.sendSignalMessage(prefixWithLabel(styledText, managed.label, this.sessions.size));
327
+ await this.sendSignalMessage(prefixWithLabel(styledText, managed.label, this.sessionManager.size));
283
328
  }
284
329
  catch (error) {
285
330
  if (error instanceof BudgetExhaustedError) {
@@ -287,12 +332,12 @@ export class SignalBotDaemon {
287
332
  await this.sendSignalMessage(prefixWithLabel(`Session budget exhausted: ${error.message}\n` +
288
333
  formatBudgetSummary(status) +
289
334
  '\n' +
290
- 'Send a new message to start a fresh session.', managed.label, this.sessions.size));
291
- await this.endSession(managed.label);
335
+ 'Send a new message to start a fresh session.', managed.label, this.sessionManager.size));
336
+ await this.sessionManager.end(managed.label);
292
337
  }
293
338
  else {
294
339
  const message = error instanceof Error ? error.message : String(error);
295
- await this.sendSignalMessage(prefixWithLabel(`Error: ${message}`, managed.label, this.sessions.size));
340
+ await this.sendSignalMessage(prefixWithLabel(`Error: ${message}`, managed.label, this.sessionManager.size));
296
341
  }
297
342
  }
298
343
  finally {
@@ -301,18 +346,18 @@ export class SignalBotDaemon {
301
346
  }
302
347
  // --- Session lifecycle ---
303
348
  /**
304
- * Creates a new session with a fresh SignalSessionTransport.
349
+ * Creates a new Signal session with a fresh SignalSessionTransport.
305
350
  * Follows the same pattern as index.ts: create transport,
306
351
  * wire callbacks, create session, start transport.
307
352
  * Returns the label of the new session.
308
353
  */
309
354
  async startNewSession() {
310
- if (this.sessions.size >= this.maxConcurrentSessions) {
355
+ // Count only Signal sessions toward the concurrency limit
356
+ const signalSessionCount = this.sessionManager.byKind('signal').length;
357
+ if (signalSessionCount >= this.maxConcurrentSessions) {
311
358
  throw new Error(`Session limit reached (max ${this.maxConcurrentSessions}). Use /quit to end a session first.`);
312
359
  }
313
- const label = this.nextLabel++;
314
360
  const transport = new SignalSessionTransport(this);
315
- transport.sessionLabel = label;
316
361
  const config = loadConfig();
317
362
  const session = await createSession({
318
363
  config,
@@ -321,81 +366,36 @@ export class SignalBotDaemon {
321
366
  onEscalationExpired: transport.createEscalationExpiredHandler(),
322
367
  onDiagnostic: transport.createDiagnosticHandler(),
323
368
  });
324
- const managed = {
325
- label,
326
- session,
327
- transport,
328
- messageInFlight: false,
329
- pendingEscalationId: null,
330
- escalationResolving: false,
331
- };
332
- this.sessions.set(label, managed);
333
- this.currentLabel = label;
369
+ const label = this.sessionManager.register(session, transport, { kind: 'signal' });
370
+ transport.sessionLabel = label;
334
371
  // Start the transport in the background. When it resolves
335
372
  // (session closed/budget exhausted), clean up.
336
373
  transport
337
374
  .run(session)
338
375
  .then(() => {
339
376
  // Transport exited - remove session if still present
340
- if (this.sessions.has(label)) {
341
- this.sessions.delete(label);
342
- if (this.currentLabel === label) {
343
- this.autoSwitchCurrent();
344
- }
377
+ const managed = this.sessionManager.get(label);
378
+ if (managed) {
379
+ this.sessionManager.end(label).catch((err) => {
380
+ logger.error(`[Signal Daemon] Failed to clean up session #${label}: ${String(err)}`);
381
+ });
345
382
  }
346
383
  })
347
384
  .catch((err) => {
348
385
  logger.error(`[Signal Daemon] Transport #${label} error: ${String(err)}`);
349
386
  });
350
- await this.sendSignalMessage(prefixWithLabel('Started a new session.', label, this.sessions.size));
387
+ await this.sendSignalMessage(prefixWithLabel('Started a new session.', label, this.sessionManager.size));
351
388
  return label;
352
389
  }
353
- /**
354
- * Ends a specific session by label and cleans up.
355
- * If it was the current session, auto-switches to the most recent remaining.
356
- */
357
- async endSession(label) {
358
- const managed = this.sessions.get(label);
359
- if (!managed)
360
- return;
361
- // Close transport first (resolves run() promise), then session.
362
- // Only remove from the map after successful close so the session
363
- // remains trackable (and retryable) if session.close() fails.
364
- managed.transport.close();
365
- await managed.session.close();
366
- this.sessions.delete(label);
367
- if (this.currentLabel === label) {
368
- this.autoSwitchCurrent();
369
- }
370
- }
371
- /**
372
- * Convenience method: ends the current session (if any).
373
- */
374
- async endCurrentSession() {
375
- if (this.currentLabel === null)
376
- return;
377
- await this.endSession(this.currentLabel);
378
- }
379
- /**
380
- * Auto-switches currentLabel to the highest remaining label (most recent),
381
- * or null if no sessions remain.
382
- */
383
- autoSwitchCurrent() {
384
- if (this.sessions.size === 0) {
385
- this.currentLabel = null;
386
- return;
387
- }
388
- this.currentLabel = Math.max(...this.sessions.keys());
389
- }
390
390
  // --- Escalation handling ---
391
391
  /**
392
392
  * Checks if the message is an escalation reply.
393
393
  * Accepts: approve, deny, /approve, /deny (case-insensitive).
394
394
  * Optionally with a session label: "approve #2", "/approve 2".
395
395
  *
396
- * Routes to the session with a pending escalation. If multiple
397
- * sessions have pending escalations and no label is specified,
398
- * asks for disambiguation.
396
+ * Routes to the session with a pending escalation.
397
+ * Works across ALL session types (signal + cron) so that
398
+ * `approve #3` from Signal can approve a cron job's escalation.
399
399
  */
400
400
  handleEscalationReply(text) {
401
401
  const normalized = text.trim().toLowerCase();
@@ -405,19 +405,14 @@ export class SignalBotDaemon {
405
405
  return false;
406
406
  const isApprove = match[1] === 'approve';
407
407
  const explicitLabel = match[2] ? parseInt(match[2], 10) : null;
408
- // Find sessions with pending escalations
409
- const pending = [];
410
- for (const managed of this.sessions.values()) {
411
- if (managed.pendingEscalationId) {
412
- pending.push(managed);
413
- }
414
- }
408
+ // Find sessions with pending escalations (across all session types)
409
+ const pending = this.sessionManager.withPendingEscalation();
415
410
  if (pending.length === 0)
416
411
  return false;
417
412
  // Determine target session
418
413
  let target;
419
414
  if (explicitLabel !== null) {
420
- target = this.sessions.get(explicitLabel);
415
+ target = this.sessionManager.get(explicitLabel);
421
416
  if (!target?.pendingEscalationId) {
422
417
  this.sendSignalMessage(`Session #${explicitLabel} has no pending escalation.`).catch(() => { });
423
418
  return true;
@@ -433,7 +428,7 @@ export class SignalBotDaemon {
433
428
  return true;
434
429
  }
435
430
  if (target.escalationResolving) {
436
- this.sendSignalMessage(prefixWithLabel('Escalation is being resolved, please wait...', target.label, this.sessions.size)).catch(() => { });
431
+ this.sendSignalMessage(prefixWithLabel('Escalation is being resolved, please wait...', target.label, this.sessionManager.size)).catch(() => { });
437
432
  return true;
438
433
  }
439
434
  const decision = isApprove ? 'approved' : 'denied';
@@ -443,11 +438,11 @@ export class SignalBotDaemon {
443
438
  managed.session
444
439
  .resolveEscalation(escalationId, decision)
445
440
  .then(() => {
446
- return this.sendSignalMessage(prefixWithLabel(`Escalation ${decision}.`, managed.label, this.sessions.size));
441
+ return this.sendSignalMessage(prefixWithLabel(`Escalation ${decision}.`, managed.label, this.sessionManager.size));
447
442
  })
448
443
  .catch((err) => {
449
444
  const msg = err instanceof Error ? err.message : String(err);
450
- this.sendSignalMessage(prefixWithLabel(`Escalation error: ${msg}`, managed.label, this.sessions.size)).catch(() => { });
445
+ this.sendSignalMessage(prefixWithLabel(`Escalation error: ${msg}`, managed.label, this.sessionManager.size)).catch(() => { });
451
446
  })
452
447
  .finally(() => {
453
448
  managed.escalationResolving = false;
@@ -459,21 +454,12 @@ export class SignalBotDaemon {
459
454
  }
460
455
  /** Called by SignalSessionTransport when the session surfaces an escalation. */
461
456
  setPendingEscalation(label, escalationId) {
462
- const managed = this.sessions.get(label);
463
- if (managed) {
464
- managed.pendingEscalationId = escalationId;
465
- logger.info(`[Signal Daemon] Escalation ${escalationId} set on session #${label}`);
466
- }
467
- else {
468
- logger.error(`[Signal Daemon] setPendingEscalation: no session #${label} in map (keys: ${[...this.sessions.keys()].join(',')})`);
469
- }
457
+ this.sessionManager.setPendingEscalation(label, escalationId);
458
+ logger.info(`[Signal Daemon] Escalation ${escalationId} set on session #${label}`);
470
459
  }
471
460
  /** Called by SignalSessionTransport when an escalation expires. */
472
461
  clearPendingEscalation(label) {
473
- const managed = this.sessions.get(label);
474
- if (managed) {
475
- managed.pendingEscalationId = null;
476
- }
462
+ this.sessionManager.clearPendingEscalation(label);
477
463
  }
478
464
  // --- Control commands ---
479
465
  handleControlCommand(text) {
@@ -483,28 +469,28 @@ export class SignalBotDaemon {
483
469
  if (quitMatch) {
484
470
  const explicitLabel = quitMatch[2] ? parseInt(quitMatch[2], 10) : null;
485
471
  this.scheduleSessionOp(async () => {
486
- const labelToEnd = explicitLabel ?? this.currentLabel;
472
+ const labelToEnd = explicitLabel ?? this.sessionManager.currentLabel;
487
473
  if (labelToEnd === null) {
488
474
  await this.sendSignalMessage('No active session.');
489
475
  return;
490
476
  }
491
- if (!this.sessions.has(labelToEnd)) {
477
+ if (!this.sessionManager.get(labelToEnd)) {
492
478
  await this.sendSignalMessage(`No session #${labelToEnd}.`);
493
479
  return;
494
480
  }
495
- const wasCurrent = labelToEnd === this.currentLabel;
481
+ const wasCurrent = labelToEnd === this.sessionManager.currentLabel;
496
482
  try {
497
- await this.endSession(labelToEnd);
483
+ await this.sessionManager.end(labelToEnd);
498
484
  }
499
485
  catch (err) {
500
486
  const msg = err instanceof Error ? err.message : String(err);
501
487
  await this.sendSignalMessage(`Failed to end session #${labelToEnd}: ${msg}`);
502
488
  return;
503
489
  }
504
- if (wasCurrent && this.sessions.size > 0 && this.currentLabel !== null) {
505
- await this.sendSignalMessage(`Session #${labelToEnd} ended. Switched to #${this.currentLabel}.`);
490
+ if (wasCurrent && this.sessionManager.size > 0 && this.sessionManager.currentLabel !== null) {
491
+ await this.sendSignalMessage(`Session #${labelToEnd} ended. Switched to #${this.sessionManager.currentLabel}.`);
506
492
  }
507
- else if (this.sessions.size > 0) {
493
+ else if (this.sessionManager.size > 0) {
508
494
  await this.sendSignalMessage(`Session #${labelToEnd} ended.`);
509
495
  }
510
496
  else {
@@ -529,7 +515,8 @@ export class SignalBotDaemon {
529
515
  // /sessions
530
516
  if (lower === '/sessions') {
531
517
  this.scheduleSessionOp(async () => {
532
- const entries = Array.from(this.sessions.values(), (managed) => {
518
+ const allSessions = this.sessionManager.all();
519
+ const entries = allSessions.map((managed) => {
533
520
  const budget = managed.session.getBudgetStatus();
534
521
  const maxTokens = budget.limits.maxTotalTokens;
535
522
  return {
@@ -538,20 +525,24 @@ export class SignalBotDaemon {
538
525
  budgetPercent: maxTokens ? Math.round((budget.totalTokens / maxTokens) * 100) : 0,
539
526
  };
540
527
  });
541
- await this.sendSignalMessage(formatSessionList(entries, this.currentLabel));
528
+ await this.sendSignalMessage(formatSessionList(entries, this.sessionManager.currentLabel));
542
529
  });
543
530
  return true;
544
531
  }
545
- // /switch N
532
+ // /switch N - only switch to Signal sessions
546
533
  const switchMatch = lower.match(/^\/switch\s+#?(\d+)$/);
547
534
  if (switchMatch) {
548
535
  const label = parseInt(switchMatch[1], 10);
549
536
  this.scheduleSessionOp(async () => {
550
- if (!this.sessions.has(label)) {
537
+ const managed = this.sessionManager.get(label);
538
+ if (!managed) {
551
539
  await this.sendSignalMessage(`No session #${label}.`);
552
540
  }
541
+ else if (managed.source.kind !== 'signal') {
542
+ await this.sendSignalMessage(`Session #${label} is a ${managed.source.kind} session and cannot be switched to.`);
543
+ }
553
544
  else {
554
- this.currentLabel = label;
545
+ this.sessionManager.currentLabel = label;
555
546
  await this.sendSignalMessage(`Switched to session #${label}.`);
556
547
  }
557
548
  });
@@ -562,8 +553,8 @@ export class SignalBotDaemon {
562
553
  if (budgetMatch) {
563
554
  const explicitBudgetLabel = budgetMatch[1] ? parseInt(budgetMatch[1], 10) : null;
564
555
  this.scheduleSessionOp(async () => {
565
- const label = explicitBudgetLabel ?? this.currentLabel;
566
- const managed = label !== null ? this.sessions.get(label) : undefined;
556
+ const label = explicitBudgetLabel ?? this.sessionManager.currentLabel;
557
+ const managed = label !== null ? this.sessionManager.get(label) : undefined;
567
558
  if (label === null) {
568
559
  await this.sendSignalMessage('No active session.');
569
560
  }
@@ -572,7 +563,29 @@ export class SignalBotDaemon {
572
563
  }
573
564
  else {
574
565
  const status = managed.session.getBudgetStatus();
575
- await this.sendSignalMessage(prefixWithLabel(formatBudgetMessage(status), label, this.sessions.size));
566
+ await this.sendSignalMessage(prefixWithLabel(formatBudgetMessage(status), label, this.sessionManager.size));
567
+ }
568
+ });
569
+ return true;
570
+ }
571
+ // /jobs
572
+ if (lower === '/jobs') {
573
+ this.scheduleSessionOp(async () => {
574
+ try {
575
+ const { sendControlRequest } = await import('../daemon/control-socket.js');
576
+ const response = await sendControlRequest({ command: 'list-jobs' });
577
+ if (response?.ok) {
578
+ const jobs = (response.data ?? []);
579
+ await this.sendSignalMessage(formatJobList(jobs));
580
+ }
581
+ else {
582
+ const errMsg = response ? response.error : 'daemon may not be running';
583
+ await this.sendSignalMessage(`Failed to query jobs: ${errMsg}`);
584
+ }
585
+ }
586
+ catch (err) {
587
+ const msg = err instanceof Error ? err.message : String(err);
588
+ await this.sendSignalMessage(`Failed to query jobs: ${msg}`);
576
589
  }
577
590
  });
578
591
  return true;
@@ -580,7 +593,8 @@ export class SignalBotDaemon {
580
593
  // /help
581
594
  if (lower === '/help') {
582
595
  this.scheduleSessionOp(async () => {
583
- const sessionLine = this.currentLabel !== null ? `Current session: #${this.currentLabel}` : 'No active session.';
596
+ const currentLabel = this.sessionManager.currentLabel;
597
+ const sessionLine = currentLabel !== null ? `Current session: #${currentLabel}` : 'No active session.';
584
598
  await this.sendSignalMessage(sessionLine +
585
599
  '\n\n' +
586
600
  'Commands:\n' +
@@ -590,6 +604,7 @@ export class SignalBotDaemon {
590
604
  ' #N <message> - send to session #N without switching\n' +
591
605
  ' /quit [N] - end session (current or #N)\n' +
592
606
  ' /budget [N] - show resource usage\n' +
607
+ ' /jobs - list scheduled cron jobs\n' +
593
608
  ' /help - show this message\n' +
594
609
  ' approve [#N] - approve pending escalation\n' +
595
610
  ' deny [#N] - deny pending escalation');