@provos/ironcurtain 0.6.0 → 0.7.1

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 (235) hide show
  1. package/README.md +29 -86
  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 +79 -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/constitution-readonly.md +21 -0
  10. package/dist/config/generated/tool-annotations.json +487 -338
  11. package/dist/config/generated-readonly/compiled-policy.json +468 -0
  12. package/dist/config/index.d.ts +21 -1
  13. package/dist/config/index.js +12 -4
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/config/mcp-servers.json +2 -8
  16. package/dist/config/paths.d.ts +47 -0
  17. package/dist/config/paths.js +81 -0
  18. package/dist/config/paths.js.map +1 -1
  19. package/dist/config/tool-description-hints.json +15 -0
  20. package/dist/config/types.d.ts +7 -0
  21. package/dist/config/user-config.d.ts +21 -0
  22. package/dist/config/user-config.js +8 -0
  23. package/dist/config/user-config.js.map +1 -1
  24. package/dist/cron/compile-task-policy.d.ts +17 -0
  25. package/dist/cron/compile-task-policy.js +39 -0
  26. package/dist/cron/compile-task-policy.js.map +1 -0
  27. package/dist/cron/constitution-generator.d.ts +60 -0
  28. package/dist/cron/constitution-generator.js +217 -0
  29. package/dist/cron/constitution-generator.js.map +1 -0
  30. package/dist/cron/cron-scheduler.d.ts +65 -0
  31. package/dist/cron/cron-scheduler.js +184 -0
  32. package/dist/cron/cron-scheduler.js.map +1 -0
  33. package/dist/cron/file-lock.d.ts +41 -0
  34. package/dist/cron/file-lock.js +156 -0
  35. package/dist/cron/file-lock.js.map +1 -0
  36. package/dist/cron/format-utils.d.ts +18 -0
  37. package/dist/cron/format-utils.js +145 -0
  38. package/dist/cron/format-utils.js.map +1 -0
  39. package/dist/cron/git-sync.d.ts +22 -0
  40. package/dist/cron/git-sync.js +121 -0
  41. package/dist/cron/git-sync.js.map +1 -0
  42. package/dist/cron/headless-transport.d.ts +27 -0
  43. package/dist/cron/headless-transport.js +43 -0
  44. package/dist/cron/headless-transport.js.map +1 -0
  45. package/dist/cron/job-commands.d.ts +39 -0
  46. package/dist/cron/job-commands.js +585 -0
  47. package/dist/cron/job-commands.js.map +1 -0
  48. package/dist/cron/job-customizer.d.ts +22 -0
  49. package/dist/cron/job-customizer.js +47 -0
  50. package/dist/cron/job-customizer.js.map +1 -0
  51. package/dist/cron/job-store.d.ts +35 -0
  52. package/dist/cron/job-store.js +111 -0
  53. package/dist/cron/job-store.js.map +1 -0
  54. package/dist/cron/types.d.ts +135 -0
  55. package/dist/cron/types.js +22 -0
  56. package/dist/cron/types.js.map +1 -0
  57. package/dist/daemon/control-socket.d.ts +127 -0
  58. package/dist/daemon/control-socket.js +261 -0
  59. package/dist/daemon/control-socket.js.map +1 -0
  60. package/dist/daemon/daemon-command.d.ts +13 -0
  61. package/dist/daemon/daemon-command.js +230 -0
  62. package/dist/daemon/daemon-command.js.map +1 -0
  63. package/dist/daemon/ironcurtain-daemon.d.ts +90 -0
  64. package/dist/daemon/ironcurtain-daemon.js +550 -0
  65. package/dist/daemon/ironcurtain-daemon.js.map +1 -0
  66. package/dist/docker/adapters/claude-code.js +7 -47
  67. package/dist/docker/adapters/claude-code.js.map +1 -1
  68. package/dist/docker/adapters/goose.d.ts +58 -0
  69. package/dist/docker/adapters/goose.js +250 -0
  70. package/dist/docker/adapters/goose.js.map +1 -0
  71. package/dist/docker/adapters/shared-scripts.d.ts +29 -0
  72. package/dist/docker/adapters/shared-scripts.js +80 -0
  73. package/dist/docker/adapters/shared-scripts.js.map +1 -0
  74. package/dist/docker/agent-adapter.d.ts +12 -0
  75. package/dist/docker/agent-adapter.js.map +1 -1
  76. package/dist/docker/agent-registry.d.ts +6 -1
  77. package/dist/docker/agent-registry.js +10 -1
  78. package/dist/docker/agent-registry.js.map +1 -1
  79. package/dist/docker/docker-agent-session.d.ts +2 -25
  80. package/dist/docker/docker-agent-session.js +7 -106
  81. package/dist/docker/docker-agent-session.js.map +1 -1
  82. package/dist/docker/docker-infrastructure.d.ts +8 -0
  83. package/dist/docker/docker-infrastructure.js +12 -11
  84. package/dist/docker/docker-infrastructure.js.map +1 -1
  85. package/dist/docker/mitm-proxy.js +10 -5
  86. package/dist/docker/mitm-proxy.js.map +1 -1
  87. package/dist/docker/oauth-credentials.js +2 -2
  88. package/dist/docker/oauth-credentials.js.map +1 -1
  89. package/dist/docker/pty-session.js +146 -39
  90. package/dist/docker/pty-session.js.map +1 -1
  91. package/dist/escalation/escalation-watcher.d.ts +2 -0
  92. package/dist/escalation/escalation-watcher.js +3 -0
  93. package/dist/escalation/escalation-watcher.js.map +1 -1
  94. package/dist/escalation/session-registry.js +1 -14
  95. package/dist/escalation/session-registry.js.map +1 -1
  96. package/dist/index.js +43 -5
  97. package/dist/index.js.map +1 -1
  98. package/dist/mux/mux-app.js +11 -6
  99. package/dist/mux/mux-app.js.map +1 -1
  100. package/dist/mux/mux-command.js +49 -10
  101. package/dist/mux/mux-command.js.map +1 -1
  102. package/dist/mux/mux-input-handler.d.ts +5 -0
  103. package/dist/mux/mux-input-handler.js +64 -9
  104. package/dist/mux/mux-input-handler.js.map +1 -1
  105. package/dist/mux/mux-renderer.d.ts +20 -0
  106. package/dist/mux/mux-renderer.js +127 -35
  107. package/dist/mux/mux-renderer.js.map +1 -1
  108. package/dist/mux/mux-splash.js +2 -1
  109. package/dist/mux/mux-splash.js.map +1 -1
  110. package/dist/mux/paste-interceptor.d.ts +22 -0
  111. package/dist/mux/paste-interceptor.js +143 -0
  112. package/dist/mux/paste-interceptor.js.map +1 -0
  113. package/dist/mux/pty-bridge.js +1 -1
  114. package/dist/mux/pty-bridge.js.map +1 -1
  115. package/dist/mux/types.d.ts +8 -1
  116. package/dist/mux/types.js +17 -5
  117. package/dist/mux/types.js.map +1 -1
  118. package/dist/persona/compile-persona-policy.d.ts +21 -0
  119. package/dist/persona/compile-persona-policy.js +50 -0
  120. package/dist/persona/compile-persona-policy.js.map +1 -0
  121. package/dist/persona/persona-command.d.ts +9 -0
  122. package/dist/persona/persona-command.js +479 -0
  123. package/dist/persona/persona-command.js.map +1 -0
  124. package/dist/persona/persona-customizer.d.ts +23 -0
  125. package/dist/persona/persona-customizer.js +52 -0
  126. package/dist/persona/persona-customizer.js.map +1 -0
  127. package/dist/persona/persona-prompt.d.ts +19 -0
  128. package/dist/persona/persona-prompt.js +42 -0
  129. package/dist/persona/persona-prompt.js.map +1 -0
  130. package/dist/persona/resolve.d.ts +46 -0
  131. package/dist/persona/resolve.js +108 -0
  132. package/dist/persona/resolve.js.map +1 -0
  133. package/dist/persona/types.d.ts +40 -0
  134. package/dist/persona/types.js +20 -0
  135. package/dist/persona/types.js.map +1 -0
  136. package/dist/pipeline/annotate.js +10 -4
  137. package/dist/pipeline/annotate.js.map +1 -1
  138. package/dist/pipeline/compile.d.ts +14 -31
  139. package/dist/pipeline/compile.js +50 -563
  140. package/dist/pipeline/compile.js.map +1 -1
  141. package/dist/pipeline/constitution-compiler.d.ts +6 -0
  142. package/dist/pipeline/constitution-compiler.js +42 -6
  143. package/dist/pipeline/constitution-compiler.js.map +1 -1
  144. package/dist/pipeline/constitution-customizer.d.ts +3 -0
  145. package/dist/pipeline/constitution-customizer.js +19 -5
  146. package/dist/pipeline/constitution-customizer.js.map +1 -1
  147. package/dist/pipeline/customizer-loop.d.ts +34 -0
  148. package/dist/pipeline/customizer-loop.js +167 -0
  149. package/dist/pipeline/customizer-loop.js.map +1 -0
  150. package/dist/pipeline/mcp-connections.d.ts +16 -0
  151. package/dist/pipeline/mcp-connections.js +61 -0
  152. package/dist/pipeline/mcp-connections.js.map +1 -0
  153. package/dist/pipeline/pipeline-runner.d.ts +86 -0
  154. package/dist/pipeline/pipeline-runner.js +547 -0
  155. package/dist/pipeline/pipeline-runner.js.map +1 -0
  156. package/dist/pipeline/pipeline-shared.d.ts +37 -3
  157. package/dist/pipeline/pipeline-shared.js +77 -16
  158. package/dist/pipeline/pipeline-shared.js.map +1 -1
  159. package/dist/pipeline/refresh-lists.js +38 -12
  160. package/dist/pipeline/refresh-lists.js.map +1 -1
  161. package/dist/pipeline/scenario-generator.js +2 -1
  162. package/dist/pipeline/scenario-generator.js.map +1 -1
  163. package/dist/pipeline/tool-annotator.d.ts +3 -3
  164. package/dist/pipeline/tool-annotator.js +114 -4
  165. package/dist/pipeline/tool-annotator.js.map +1 -1
  166. package/dist/pipeline/types.d.ts +52 -0
  167. package/dist/sandbox/index.js +36 -1
  168. package/dist/sandbox/index.js.map +1 -1
  169. package/dist/session/agent-session.d.ts +3 -0
  170. package/dist/session/agent-session.js +10 -1
  171. package/dist/session/agent-session.js.map +1 -1
  172. package/dist/session/index.d.ts +10 -0
  173. package/dist/session/index.js +134 -12
  174. package/dist/session/index.js.map +1 -1
  175. package/dist/session/preflight.js +26 -6
  176. package/dist/session/preflight.js.map +1 -1
  177. package/dist/session/prompts.d.ts +14 -0
  178. package/dist/session/prompts.js +39 -2
  179. package/dist/session/prompts.js.map +1 -1
  180. package/dist/session/session-manager.d.ts +105 -0
  181. package/dist/session/session-manager.js +134 -0
  182. package/dist/session/session-manager.js.map +1 -0
  183. package/dist/session/session-metadata.d.ts +18 -0
  184. package/dist/session/session-metadata.js +34 -0
  185. package/dist/session/session-metadata.js.map +1 -0
  186. package/dist/session/types.d.ts +47 -0
  187. package/dist/session/workspace-validation.d.ts +2 -0
  188. package/dist/session/workspace-validation.js +6 -5
  189. package/dist/session/workspace-validation.js.map +1 -1
  190. package/dist/signal/bot-command.d.ts +2 -0
  191. package/dist/signal/bot-command.js +8 -0
  192. package/dist/signal/bot-command.js.map +1 -1
  193. package/dist/signal/format.d.ts +27 -0
  194. package/dist/signal/format.js +59 -0
  195. package/dist/signal/format.js.map +1 -1
  196. package/dist/signal/signal-bot-daemon.d.ts +27 -25
  197. package/dist/signal/signal-bot-daemon.js +153 -135
  198. package/dist/signal/signal-bot-daemon.js.map +1 -1
  199. package/dist/signal/signal-container.js +63 -19
  200. package/dist/signal/signal-container.js.map +1 -1
  201. package/dist/signal/signal-transport.js +3 -8
  202. package/dist/signal/signal-transport.js.map +1 -1
  203. package/dist/trusted-process/domain-utils.d.ts +37 -4
  204. package/dist/trusted-process/domain-utils.js +130 -11
  205. package/dist/trusted-process/domain-utils.js.map +1 -1
  206. package/dist/trusted-process/index.js +6 -2
  207. package/dist/trusted-process/index.js.map +1 -1
  208. package/dist/trusted-process/mcp-client-manager.js +11 -5
  209. package/dist/trusted-process/mcp-client-manager.js.map +1 -1
  210. package/dist/trusted-process/mcp-proxy-server.d.ts +21 -0
  211. package/dist/trusted-process/mcp-proxy-server.js +91 -28
  212. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  213. package/dist/trusted-process/permissive-output-validator.d.ts +23 -0
  214. package/dist/trusted-process/permissive-output-validator.js +10 -0
  215. package/dist/trusted-process/permissive-output-validator.js.map +1 -0
  216. package/dist/trusted-process/policy-engine.d.ts +29 -7
  217. package/dist/trusted-process/policy-engine.js +149 -92
  218. package/dist/trusted-process/policy-engine.js.map +1 -1
  219. package/dist/trusted-process/tool-description-hints.d.ts +19 -0
  220. package/dist/trusted-process/tool-description-hints.js +53 -0
  221. package/dist/trusted-process/tool-description-hints.js.map +1 -0
  222. package/dist/types/argument-roles.d.ts +33 -1
  223. package/dist/types/argument-roles.js +129 -1
  224. package/dist/types/argument-roles.js.map +1 -1
  225. package/dist/types/slug.d.ts +7 -0
  226. package/dist/types/slug.js +14 -0
  227. package/dist/types/slug.js.map +1 -0
  228. package/dist/utils/editor.d.ts +24 -0
  229. package/dist/utils/editor.js +66 -0
  230. package/dist/utils/editor.js.map +1 -0
  231. package/docker/Dockerfile.goose +34 -0
  232. package/docker/entrypoint-goose.sh +30 -0
  233. package/package.json +1 -1
  234. package/src/config/generated/tool-annotations.json +487 -338
  235. 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,101 +346,57 @@ 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
- async startNewSession() {
310
- if (this.sessions.size >= this.maxConcurrentSessions) {
354
+ async startNewSession(persona) {
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,
319
364
  mode: this.mode,
365
+ persona,
320
366
  onEscalation: transport.createEscalationHandler(),
321
367
  onEscalationExpired: transport.createEscalationExpiredHandler(),
322
368
  onDiagnostic: transport.createDiagnosticHandler(),
323
369
  });
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;
370
+ const label = this.sessionManager.register(session, transport, { kind: 'signal' });
371
+ transport.sessionLabel = label;
334
372
  // Start the transport in the background. When it resolves
335
373
  // (session closed/budget exhausted), clean up.
336
374
  transport
337
375
  .run(session)
338
376
  .then(() => {
339
377
  // 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
- }
378
+ const managed = this.sessionManager.get(label);
379
+ if (managed) {
380
+ this.sessionManager.end(label).catch((err) => {
381
+ logger.error(`[Signal Daemon] Failed to clean up session #${label}: ${String(err)}`);
382
+ });
345
383
  }
346
384
  })
347
385
  .catch((err) => {
348
386
  logger.error(`[Signal Daemon] Transport #${label} error: ${String(err)}`);
349
387
  });
350
- await this.sendSignalMessage(prefixWithLabel('Started a new session.', label, this.sessions.size));
388
+ await this.sendSignalMessage(prefixWithLabel('Started a new session.', label, this.sessionManager.size));
351
389
  return label;
352
390
  }
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
391
  // --- Escalation handling ---
391
392
  /**
392
393
  * Checks if the message is an escalation reply.
393
394
  * Accepts: approve, deny, /approve, /deny (case-insensitive).
394
395
  * Optionally with a session label: "approve #2", "/approve 2".
395
396
  *
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.
397
+ * Routes to the session with a pending escalation.
398
+ * Works across ALL session types (signal + cron) so that
399
+ * `approve #3` from Signal can approve a cron job's escalation.
399
400
  */
400
401
  handleEscalationReply(text) {
401
402
  const normalized = text.trim().toLowerCase();
@@ -405,19 +406,14 @@ export class SignalBotDaemon {
405
406
  return false;
406
407
  const isApprove = match[1] === 'approve';
407
408
  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
- }
409
+ // Find sessions with pending escalations (across all session types)
410
+ const pending = this.sessionManager.withPendingEscalation();
415
411
  if (pending.length === 0)
416
412
  return false;
417
413
  // Determine target session
418
414
  let target;
419
415
  if (explicitLabel !== null) {
420
- target = this.sessions.get(explicitLabel);
416
+ target = this.sessionManager.get(explicitLabel);
421
417
  if (!target?.pendingEscalationId) {
422
418
  this.sendSignalMessage(`Session #${explicitLabel} has no pending escalation.`).catch(() => { });
423
419
  return true;
@@ -433,7 +429,7 @@ export class SignalBotDaemon {
433
429
  return true;
434
430
  }
435
431
  if (target.escalationResolving) {
436
- this.sendSignalMessage(prefixWithLabel('Escalation is being resolved, please wait...', target.label, this.sessions.size)).catch(() => { });
432
+ this.sendSignalMessage(prefixWithLabel('Escalation is being resolved, please wait...', target.label, this.sessionManager.size)).catch(() => { });
437
433
  return true;
438
434
  }
439
435
  const decision = isApprove ? 'approved' : 'denied';
@@ -443,11 +439,11 @@ export class SignalBotDaemon {
443
439
  managed.session
444
440
  .resolveEscalation(escalationId, decision)
445
441
  .then(() => {
446
- return this.sendSignalMessage(prefixWithLabel(`Escalation ${decision}.`, managed.label, this.sessions.size));
442
+ return this.sendSignalMessage(prefixWithLabel(`Escalation ${decision}.`, managed.label, this.sessionManager.size));
447
443
  })
448
444
  .catch((err) => {
449
445
  const msg = err instanceof Error ? err.message : String(err);
450
- this.sendSignalMessage(prefixWithLabel(`Escalation error: ${msg}`, managed.label, this.sessions.size)).catch(() => { });
446
+ this.sendSignalMessage(prefixWithLabel(`Escalation error: ${msg}`, managed.label, this.sessionManager.size)).catch(() => { });
451
447
  })
452
448
  .finally(() => {
453
449
  managed.escalationResolving = false;
@@ -459,21 +455,12 @@ export class SignalBotDaemon {
459
455
  }
460
456
  /** Called by SignalSessionTransport when the session surfaces an escalation. */
461
457
  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
- }
458
+ this.sessionManager.setPendingEscalation(label, escalationId);
459
+ logger.info(`[Signal Daemon] Escalation ${escalationId} set on session #${label}`);
470
460
  }
471
461
  /** Called by SignalSessionTransport when an escalation expires. */
472
462
  clearPendingEscalation(label) {
473
- const managed = this.sessions.get(label);
474
- if (managed) {
475
- managed.pendingEscalationId = null;
476
- }
463
+ this.sessionManager.clearPendingEscalation(label);
477
464
  }
478
465
  // --- Control commands ---
479
466
  handleControlCommand(text) {
@@ -483,28 +470,28 @@ export class SignalBotDaemon {
483
470
  if (quitMatch) {
484
471
  const explicitLabel = quitMatch[2] ? parseInt(quitMatch[2], 10) : null;
485
472
  this.scheduleSessionOp(async () => {
486
- const labelToEnd = explicitLabel ?? this.currentLabel;
473
+ const labelToEnd = explicitLabel ?? this.sessionManager.currentLabel;
487
474
  if (labelToEnd === null) {
488
475
  await this.sendSignalMessage('No active session.');
489
476
  return;
490
477
  }
491
- if (!this.sessions.has(labelToEnd)) {
478
+ if (!this.sessionManager.get(labelToEnd)) {
492
479
  await this.sendSignalMessage(`No session #${labelToEnd}.`);
493
480
  return;
494
481
  }
495
- const wasCurrent = labelToEnd === this.currentLabel;
482
+ const wasCurrent = labelToEnd === this.sessionManager.currentLabel;
496
483
  try {
497
- await this.endSession(labelToEnd);
484
+ await this.sessionManager.end(labelToEnd);
498
485
  }
499
486
  catch (err) {
500
487
  const msg = err instanceof Error ? err.message : String(err);
501
488
  await this.sendSignalMessage(`Failed to end session #${labelToEnd}: ${msg}`);
502
489
  return;
503
490
  }
504
- if (wasCurrent && this.sessions.size > 0 && this.currentLabel !== null) {
505
- await this.sendSignalMessage(`Session #${labelToEnd} ended. Switched to #${this.currentLabel}.`);
491
+ if (wasCurrent && this.sessionManager.size > 0 && this.sessionManager.currentLabel !== null) {
492
+ await this.sendSignalMessage(`Session #${labelToEnd} ended. Switched to #${this.sessionManager.currentLabel}.`);
506
493
  }
507
- else if (this.sessions.size > 0) {
494
+ else if (this.sessionManager.size > 0) {
508
495
  await this.sendSignalMessage(`Session #${labelToEnd} ended.`);
509
496
  }
510
497
  else {
@@ -513,11 +500,13 @@ export class SignalBotDaemon {
513
500
  });
514
501
  return true;
515
502
  }
516
- // /new
517
- if (lower === '/new') {
503
+ // /new [persona-name]
504
+ const newMatch = lower.match(/^\/new(?:\s+(\S+))?$/);
505
+ if (newMatch) {
506
+ const personaArg = newMatch[1]; // undefined if bare /new
518
507
  this.scheduleSessionOp(async () => {
519
508
  try {
520
- await this.startNewSession();
509
+ await this.startNewSession(personaArg);
521
510
  }
522
511
  catch (err) {
523
512
  const msg = err instanceof Error ? err.message : String(err);
@@ -529,7 +518,8 @@ export class SignalBotDaemon {
529
518
  // /sessions
530
519
  if (lower === '/sessions') {
531
520
  this.scheduleSessionOp(async () => {
532
- const entries = Array.from(this.sessions.values(), (managed) => {
521
+ const allSessions = this.sessionManager.all();
522
+ const entries = allSessions.map((managed) => {
533
523
  const budget = managed.session.getBudgetStatus();
534
524
  const maxTokens = budget.limits.maxTotalTokens;
535
525
  return {
@@ -538,20 +528,24 @@ export class SignalBotDaemon {
538
528
  budgetPercent: maxTokens ? Math.round((budget.totalTokens / maxTokens) * 100) : 0,
539
529
  };
540
530
  });
541
- await this.sendSignalMessage(formatSessionList(entries, this.currentLabel));
531
+ await this.sendSignalMessage(formatSessionList(entries, this.sessionManager.currentLabel));
542
532
  });
543
533
  return true;
544
534
  }
545
- // /switch N
535
+ // /switch N - only switch to Signal sessions
546
536
  const switchMatch = lower.match(/^\/switch\s+#?(\d+)$/);
547
537
  if (switchMatch) {
548
538
  const label = parseInt(switchMatch[1], 10);
549
539
  this.scheduleSessionOp(async () => {
550
- if (!this.sessions.has(label)) {
540
+ const managed = this.sessionManager.get(label);
541
+ if (!managed) {
551
542
  await this.sendSignalMessage(`No session #${label}.`);
552
543
  }
544
+ else if (managed.source.kind !== 'signal') {
545
+ await this.sendSignalMessage(`Session #${label} is a ${managed.source.kind} session and cannot be switched to.`);
546
+ }
553
547
  else {
554
- this.currentLabel = label;
548
+ this.sessionManager.currentLabel = label;
555
549
  await this.sendSignalMessage(`Switched to session #${label}.`);
556
550
  }
557
551
  });
@@ -562,8 +556,8 @@ export class SignalBotDaemon {
562
556
  if (budgetMatch) {
563
557
  const explicitBudgetLabel = budgetMatch[1] ? parseInt(budgetMatch[1], 10) : null;
564
558
  this.scheduleSessionOp(async () => {
565
- const label = explicitBudgetLabel ?? this.currentLabel;
566
- const managed = label !== null ? this.sessions.get(label) : undefined;
559
+ const label = explicitBudgetLabel ?? this.sessionManager.currentLabel;
560
+ const managed = label !== null ? this.sessionManager.get(label) : undefined;
567
561
  if (label === null) {
568
562
  await this.sendSignalMessage('No active session.');
569
563
  }
@@ -572,7 +566,29 @@ export class SignalBotDaemon {
572
566
  }
573
567
  else {
574
568
  const status = managed.session.getBudgetStatus();
575
- await this.sendSignalMessage(prefixWithLabel(formatBudgetMessage(status), label, this.sessions.size));
569
+ await this.sendSignalMessage(prefixWithLabel(formatBudgetMessage(status), label, this.sessionManager.size));
570
+ }
571
+ });
572
+ return true;
573
+ }
574
+ // /jobs
575
+ if (lower === '/jobs') {
576
+ this.scheduleSessionOp(async () => {
577
+ try {
578
+ const { sendControlRequest } = await import('../daemon/control-socket.js');
579
+ const response = await sendControlRequest({ command: 'list-jobs' });
580
+ if (response?.ok) {
581
+ const jobs = (response.data ?? []);
582
+ await this.sendSignalMessage(formatJobList(jobs));
583
+ }
584
+ else {
585
+ const errMsg = response ? response.error : 'daemon may not be running';
586
+ await this.sendSignalMessage(`Failed to query jobs: ${errMsg}`);
587
+ }
588
+ }
589
+ catch (err) {
590
+ const msg = err instanceof Error ? err.message : String(err);
591
+ await this.sendSignalMessage(`Failed to query jobs: ${msg}`);
576
592
  }
577
593
  });
578
594
  return true;
@@ -580,16 +596,18 @@ export class SignalBotDaemon {
580
596
  // /help
581
597
  if (lower === '/help') {
582
598
  this.scheduleSessionOp(async () => {
583
- const sessionLine = this.currentLabel !== null ? `Current session: #${this.currentLabel}` : 'No active session.';
599
+ const currentLabel = this.sessionManager.currentLabel;
600
+ const sessionLine = currentLabel !== null ? `Current session: #${currentLabel}` : 'No active session.';
584
601
  await this.sendSignalMessage(sessionLine +
585
602
  '\n\n' +
586
603
  'Commands:\n' +
587
- ' /new - start a new session\n' +
604
+ ' /new [persona] - start a new session (optionally with a persona)\n' +
588
605
  ' /sessions - list active sessions\n' +
589
606
  ' /switch N - switch to session #N\n' +
590
607
  ' #N <message> - send to session #N without switching\n' +
591
608
  ' /quit [N] - end session (current or #N)\n' +
592
609
  ' /budget [N] - show resource usage\n' +
610
+ ' /jobs - list scheduled cron jobs\n' +
593
611
  ' /help - show this message\n' +
594
612
  ' approve [#N] - approve pending escalation\n' +
595
613
  ' deny [#N] - deny pending escalation');