@provos/ironcurtain 0.4.0 → 0.5.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 (161) hide show
  1. package/README.md +219 -248
  2. package/dist/cli.js +10 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/config-command.js +144 -6
  5. package/dist/config/config-command.js.map +1 -1
  6. package/dist/config/constitution-user-base.md +2 -0
  7. package/dist/config/first-start.js +77 -12
  8. package/dist/config/first-start.js.map +1 -1
  9. package/dist/config/generated/compiled-policy.json +199 -72
  10. package/dist/config/generated/tool-annotations.json +1018 -36
  11. package/dist/config/index.js +6 -0
  12. package/dist/config/index.js.map +1 -1
  13. package/dist/config/mcp-servers.json +13 -0
  14. package/dist/config/paths.d.ts +31 -0
  15. package/dist/config/paths.js +52 -2
  16. package/dist/config/paths.js.map +1 -1
  17. package/dist/config/types.d.ts +10 -0
  18. package/dist/config/user-config.d.ts +21 -3
  19. package/dist/config/user-config.js +34 -3
  20. package/dist/config/user-config.js.map +1 -1
  21. package/dist/docker/adapters/claude-code.d.ts +4 -0
  22. package/dist/docker/adapters/claude-code.js +96 -37
  23. package/dist/docker/adapters/claude-code.js.map +1 -1
  24. package/dist/docker/agent-adapter.d.ts +22 -13
  25. package/dist/docker/code-mode-proxy.d.ts +34 -0
  26. package/dist/docker/code-mode-proxy.js +137 -0
  27. package/dist/docker/code-mode-proxy.js.map +1 -0
  28. package/dist/docker/docker-agent-session.d.ts +22 -14
  29. package/dist/docker/docker-agent-session.js +144 -117
  30. package/dist/docker/docker-agent-session.js.map +1 -1
  31. package/dist/docker/docker-infrastructure.d.ts +51 -0
  32. package/dist/docker/docker-infrastructure.js +261 -0
  33. package/dist/docker/docker-infrastructure.js.map +1 -0
  34. package/dist/docker/docker-manager.js +27 -0
  35. package/dist/docker/docker-manager.js.map +1 -1
  36. package/dist/docker/mitm-proxy.js +4 -0
  37. package/dist/docker/mitm-proxy.js.map +1 -1
  38. package/dist/docker/oauth-credentials.d.ts +81 -0
  39. package/dist/docker/oauth-credentials.js +178 -0
  40. package/dist/docker/oauth-credentials.js.map +1 -0
  41. package/dist/docker/orientation.d.ts +3 -2
  42. package/dist/docker/orientation.js +4 -4
  43. package/dist/docker/orientation.js.map +1 -1
  44. package/dist/docker/provider-config.d.ts +3 -0
  45. package/dist/docker/provider-config.js +28 -0
  46. package/dist/docker/provider-config.js.map +1 -1
  47. package/dist/docker/pty-session.d.ts +27 -0
  48. package/dist/docker/pty-session.js +420 -0
  49. package/dist/docker/pty-session.js.map +1 -0
  50. package/dist/docker/pty-types.d.ts +24 -0
  51. package/dist/docker/pty-types.js +12 -0
  52. package/dist/docker/pty-types.js.map +1 -0
  53. package/dist/docker/types.d.ts +18 -2
  54. package/dist/escalation/escalation-watcher.d.ts +44 -0
  55. package/dist/escalation/escalation-watcher.js +92 -0
  56. package/dist/escalation/escalation-watcher.js.map +1 -0
  57. package/dist/escalation/listener-command.d.ts +10 -0
  58. package/dist/escalation/listener-command.js +407 -0
  59. package/dist/escalation/listener-command.js.map +1 -0
  60. package/dist/escalation/listener-state.d.ts +49 -0
  61. package/dist/escalation/listener-state.js +104 -0
  62. package/dist/escalation/listener-state.js.map +1 -0
  63. package/dist/escalation/session-registry.d.ts +17 -0
  64. package/dist/escalation/session-registry.js +98 -0
  65. package/dist/escalation/session-registry.js.map +1 -0
  66. package/dist/index.js +15 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/logger.js +2 -3
  69. package/dist/logger.js.map +1 -1
  70. package/dist/pipeline/annotate.js +35 -15
  71. package/dist/pipeline/annotate.js.map +1 -1
  72. package/dist/pipeline/constitution-customizer.d.ts +2 -1
  73. package/dist/pipeline/constitution-customizer.js +36 -3
  74. package/dist/pipeline/constitution-customizer.js.map +1 -1
  75. package/dist/pipeline/github-identity.d.ts +38 -0
  76. package/dist/pipeline/github-identity.js +64 -0
  77. package/dist/pipeline/github-identity.js.map +1 -0
  78. package/dist/pipeline/handwritten-scenarios.js +34 -0
  79. package/dist/pipeline/handwritten-scenarios.js.map +1 -1
  80. package/dist/sandbox/index.d.ts +25 -1
  81. package/dist/sandbox/index.js +90 -12
  82. package/dist/sandbox/index.js.map +1 -1
  83. package/dist/servers/fetch-server.d.ts +6 -1
  84. package/dist/servers/fetch-server.js +26 -14
  85. package/dist/servers/fetch-server.js.map +1 -1
  86. package/dist/session/agent-session.js +9 -5
  87. package/dist/session/agent-session.js.map +1 -1
  88. package/dist/session/base-transport.d.ts +19 -0
  89. package/dist/session/base-transport.js +36 -0
  90. package/dist/session/base-transport.js.map +1 -0
  91. package/dist/session/cli-transport.d.ts +3 -3
  92. package/dist/session/cli-transport.js +7 -4
  93. package/dist/session/cli-transport.js.map +1 -1
  94. package/dist/session/index.js +24 -106
  95. package/dist/session/index.js.map +1 -1
  96. package/dist/session/interaction-log.d.ts +13 -0
  97. package/dist/session/interaction-log.js +28 -0
  98. package/dist/session/interaction-log.js.map +1 -0
  99. package/dist/session/preflight.d.ts +3 -0
  100. package/dist/session/preflight.js +45 -18
  101. package/dist/session/preflight.js.map +1 -1
  102. package/dist/session/prompts.d.ts +9 -3
  103. package/dist/session/prompts.js +28 -10
  104. package/dist/session/prompts.js.map +1 -1
  105. package/dist/session/types.d.ts +2 -0
  106. package/dist/signal/bot-command.js +5 -0
  107. package/dist/signal/bot-command.js.map +1 -1
  108. package/dist/signal/format.d.ts +21 -1
  109. package/dist/signal/format.js +41 -3
  110. package/dist/signal/format.js.map +1 -1
  111. package/dist/signal/signal-bot-daemon.d.ts +53 -18
  112. package/dist/signal/signal-bot-daemon.js +344 -106
  113. package/dist/signal/signal-bot-daemon.js.map +1 -1
  114. package/dist/signal/signal-config.d.ts +4 -0
  115. package/dist/signal/signal-config.js +2 -0
  116. package/dist/signal/signal-config.js.map +1 -1
  117. package/dist/signal/signal-transport.d.ts +13 -7
  118. package/dist/signal/signal-transport.js +36 -12
  119. package/dist/signal/signal-transport.js.map +1 -1
  120. package/dist/trusted-process/audit-log.d.ts +6 -1
  121. package/dist/trusted-process/audit-log.js +22 -2
  122. package/dist/trusted-process/audit-log.js.map +1 -1
  123. package/dist/trusted-process/audit-redactor.d.ts +25 -0
  124. package/dist/trusted-process/audit-redactor.js +121 -0
  125. package/dist/trusted-process/audit-redactor.js.map +1 -0
  126. package/dist/trusted-process/escalation.d.ts +1 -1
  127. package/dist/trusted-process/escalation.js +6 -1
  128. package/dist/trusted-process/escalation.js.map +1 -1
  129. package/dist/trusted-process/index.d.ts +2 -1
  130. package/dist/trusted-process/index.js +24 -6
  131. package/dist/trusted-process/index.js.map +1 -1
  132. package/dist/trusted-process/mcp-client-manager.js +14 -2
  133. package/dist/trusted-process/mcp-client-manager.js.map +1 -1
  134. package/dist/trusted-process/mcp-error-utils.d.ts +11 -0
  135. package/dist/trusted-process/mcp-error-utils.js +50 -0
  136. package/dist/trusted-process/mcp-error-utils.js.map +1 -0
  137. package/dist/trusted-process/mcp-proxy-server.d.ts +115 -1
  138. package/dist/trusted-process/mcp-proxy-server.js +318 -267
  139. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  140. package/dist/trusted-process/policy-engine.d.ts +3 -1
  141. package/dist/trusted-process/policy-engine.js +17 -6
  142. package/dist/trusted-process/policy-engine.js.map +1 -1
  143. package/dist/trusted-process/sandbox-integration.d.ts +16 -4
  144. package/dist/trusted-process/sandbox-integration.js +14 -8
  145. package/dist/trusted-process/sandbox-integration.js.map +1 -1
  146. package/dist/trusted-process/server-context.d.ts +21 -0
  147. package/dist/trusted-process/server-context.js +42 -0
  148. package/dist/trusted-process/server-context.js.map +1 -0
  149. package/dist/types/argument-roles.d.ts +12 -2
  150. package/dist/types/argument-roles.js +37 -4
  151. package/dist/types/argument-roles.js.map +1 -1
  152. package/docker/Dockerfile.claude-code +8 -3
  153. package/docker/entrypoint-claude-code.sh +64 -4
  154. package/package.json +19 -9
  155. package/src/config/constitution-user-base.md +2 -0
  156. package/src/config/generated/compiled-policy.json +199 -72
  157. package/src/config/generated/tool-annotations.json +1018 -36
  158. package/src/config/mcp-servers.json +13 -0
  159. package/dist/docker/managed-proxy.d.ts +0 -31
  160. package/dist/docker/managed-proxy.js +0 -265
  161. package/dist/docker/managed-proxy.js.map +0 -1
@@ -5,9 +5,9 @@
5
5
  * - WebSocket connection to signal-cli-rest-api with reconnect + exponential backoff
6
6
  * - Message routing: authorization, identity verification, escalation replies,
7
7
  * control commands, and session forwarding
8
- * - Session lifecycle: creates transport+session on demand, cleans up on end
8
+ * - Multi-session lifecycle: creates transport+session on demand, cleans up on end
9
9
  * - Identity verification with TTL cache (5 min), fail-closed
10
- * - Escalation state with race prevention
10
+ * - Escalation state with race prevention (per-session)
11
11
  * - Message sending via POST /v2/send with text_mode: "styled"
12
12
  */
13
13
  import { createSession } from '../session/index.js';
@@ -16,7 +16,7 @@ import { loadUserConfig } from '../config/user-config.js';
16
16
  import { resolveSignalConfig } from './signal-config.js';
17
17
  import { SignalSessionTransport } from './signal-transport.js';
18
18
  import { markdownToSignal } from './markdown-to-signal.js';
19
- import { formatBudgetMessage, formatBudgetSummary, splitMessage, SIGNAL_MAX_MESSAGE_LENGTH } from './format.js';
19
+ import { formatBudgetMessage, formatBudgetSummary, formatSessionList, prefixWithLabel, splitMessage, SIGNAL_MAX_MESSAGE_LENGTH, } from './format.js';
20
20
  import { BudgetExhaustedError } from '../session/errors.js';
21
21
  import * as logger from '../logger.js';
22
22
  /** How often to proactively verify the recipient's identity key (ms). */
@@ -32,25 +32,38 @@ export class SignalBotDaemon {
32
32
  reconnectAttempts = 0;
33
33
  static MAX_RECONNECT_DELAY_MS = 30_000;
34
34
  static BASE_RECONNECT_DELAY_MS = 1_000;
35
- // Session state - at most one active session at a time
36
- activeSession = null;
37
- activeTransport = null;
38
- messageInFlight = false;
39
- // Escalation state - owned by the daemon because the daemon routes
40
- // "approve"/"deny" messages before they reach the transport.
41
- pendingEscalationId = null;
42
- escalationResolving = false;
35
+ // Multi-session state
36
+ sessions = new Map();
37
+ currentLabel = null;
38
+ nextLabel = 1;
39
+ maxConcurrentSessions;
43
40
  // Identity verification state
44
41
  identityLocked = false;
45
42
  lastIdentityCheckMs = 0;
46
43
  // Message deduplication for drainMissedMessages()
47
44
  recentTimestamps = new Set();
45
+ // Serializes session-mutating operations (/new, /quit) so that
46
+ // routeToSession() sees the updated currentLabel after the operation completes.
47
+ // Without this, the fire-and-forget WebSocket handler can process a user message
48
+ // concurrently with /new, routing it to the old session.
49
+ sessionOpInProgress = Promise.resolve();
48
50
  // Resolves when the daemon should exit (shutdown() called)
49
51
  exitResolve = null;
50
52
  constructor(options) {
51
53
  this.config = options.config;
52
54
  this.containerManager = options.containerManager;
53
55
  this.mode = options.mode;
56
+ this.maxConcurrentSessions = options.config.maxConcurrentSessions;
57
+ }
58
+ /**
59
+ * Schedules a session-mutating operation (/new, /quit) so that
60
+ * concurrent routeToSession() calls wait for it to complete.
61
+ * Operations are chained to prevent interleaving.
62
+ */
63
+ scheduleSessionOp(op) {
64
+ this.sessionOpInProgress = this.sessionOpInProgress.then(op).catch((err) => {
65
+ logger.error(`[Signal Daemon] Session operation failed: ${String(err)}`);
66
+ });
54
67
  }
55
68
  /**
56
69
  * Starts the daemon. Returns a promise that resolves when
@@ -67,14 +80,20 @@ export class SignalBotDaemon {
67
80
  });
68
81
  }
69
82
  /**
70
- * Initiates graceful shutdown. Ends the active session,
83
+ * Initiates graceful shutdown. Ends all active sessions,
71
84
  * closes the WebSocket, and unblocks start().
72
85
  */
73
86
  async shutdown() {
74
87
  logger.info('[Signal Daemon] Shutting down...');
75
88
  this.closed = true;
76
89
  await this.sendSignalMessage('IronCurtain bot is shutting down. Goodbye.').catch(() => { });
77
- await this.endCurrentSession();
90
+ // Wait for any in-flight session operations (e.g., /new) to complete
91
+ 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)}`);
96
+ })));
78
97
  if (this.ws) {
79
98
  this.ws.close();
80
99
  this.ws = null;
@@ -180,26 +199,56 @@ export class SignalBotDaemon {
180
199
  // Escalation replies: approve/deny (also accept /approve, /deny)
181
200
  if (this.handleEscalationReply(text))
182
201
  return;
183
- // Control commands: /quit, /new, /budget, /help
202
+ // Control commands: /quit, /new, /sessions, /switch, /budget, /help
184
203
  if (this.handleControlCommand(text))
185
204
  return;
186
205
  // Regular message -> route to session
187
206
  await this.routeToSession(text);
188
207
  }
189
208
  /**
190
- * Routes a user message to the active session. Creates a new
209
+ * Routes a user message to the current session. Creates a new
191
210
  * session if none exists. Handles BudgetExhaustedError by ending
192
211
  * the exhausted session and notifying the user.
212
+ *
213
+ * Supports one-shot `#N` prefix routing: `#2 do something` routes
214
+ * "do something" to session #2 without changing currentLabel.
193
215
  */
194
216
  async routeToSession(text) {
195
- if (this.messageInFlight) {
196
- await this.sendSignalMessage('Still processing previous message, please wait...');
217
+ // Wait for any pending session operation (/new, /quit) to complete
218
+ // before routing, so we see the updated currentLabel.
219
+ await this.sessionOpInProgress;
220
+ // Parse optional #N prefix for one-shot routing
221
+ const { targetLabel, messageText } = parseHashPrefix(text);
222
+ // If #N prefix was used, route directly to that session (no auto-create)
223
+ if (targetLabel !== null) {
224
+ const managed = this.sessions.get(targetLabel);
225
+ if (!managed) {
226
+ await this.sendSignalMessage(`No session #${targetLabel}.`);
227
+ return;
228
+ }
229
+ await this.forwardToSession(managed, messageText);
197
230
  return;
198
231
  }
199
- // Create session on demand
200
- if (!this.activeSession) {
232
+ // Create session on demand if none exists.
233
+ // Serialize through scheduleSessionOp so concurrent messages don't
234
+ // race to create multiple sessions.
235
+ if (this.currentLabel === null) {
236
+ const createOp = new Promise((resolve, reject) => {
237
+ this.scheduleSessionOp(async () => {
238
+ try {
239
+ // Re-check after serialization — another message may have created one
240
+ if (this.currentLabel === null) {
241
+ await this.startNewSession();
242
+ }
243
+ resolve();
244
+ }
245
+ catch (err) {
246
+ reject(err instanceof Error ? err : new Error(String(err)));
247
+ }
248
+ });
249
+ });
201
250
  try {
202
- await this.startNewSession();
251
+ await createOp;
203
252
  }
204
253
  catch (err) {
205
254
  const msg = err instanceof Error ? err.message : String(err);
@@ -207,31 +256,47 @@ export class SignalBotDaemon {
207
256
  return;
208
257
  }
209
258
  }
210
- const session = this.activeSession;
211
- if (!session)
259
+ if (this.currentLabel === null)
260
+ return;
261
+ const managed = this.sessions.get(this.currentLabel);
262
+ if (!managed)
212
263
  return;
213
- this.messageInFlight = true;
264
+ await this.forwardToSession(managed, messageText);
265
+ }
266
+ /**
267
+ * Forwards a message to a specific managed session. Handles
268
+ * in-flight guards, BudgetExhaustedError, and response formatting.
269
+ */
270
+ async forwardToSession(managed, text) {
271
+ logger.info(`[Signal Daemon] Routing message to session #${managed.label} ` +
272
+ `(currentLabel=${this.currentLabel}, sessions=${[...this.sessions.keys()].join(',')}, ` +
273
+ `transportLabel=${managed.transport.sessionLabel})`);
274
+ if (managed.messageInFlight) {
275
+ await this.sendSignalMessage(prefixWithLabel('Still processing previous message, please wait...', managed.label, this.sessions.size));
276
+ return;
277
+ }
278
+ managed.messageInFlight = true;
214
279
  try {
215
- const response = await session.sendMessage(text);
280
+ const response = await managed.transport.forwardMessage(text);
216
281
  const styledText = markdownToSignal(response);
217
- await this.sendSignalMessage(styledText);
282
+ await this.sendSignalMessage(prefixWithLabel(styledText, managed.label, this.sessions.size));
218
283
  }
219
284
  catch (error) {
220
285
  if (error instanceof BudgetExhaustedError) {
221
- const status = session.getBudgetStatus();
222
- await this.sendSignalMessage(`Session budget exhausted: ${error.message}\n` +
286
+ const status = managed.session.getBudgetStatus();
287
+ await this.sendSignalMessage(prefixWithLabel(`Session budget exhausted: ${error.message}\n` +
223
288
  formatBudgetSummary(status) +
224
289
  '\n' +
225
- 'Send a new message to start a fresh session.');
226
- await this.endCurrentSession();
290
+ 'Send a new message to start a fresh session.', managed.label, this.sessions.size));
291
+ await this.endSession(managed.label);
227
292
  }
228
293
  else {
229
294
  const message = error instanceof Error ? error.message : String(error);
230
- await this.sendSignalMessage(`Error: ${message}`);
295
+ await this.sendSignalMessage(prefixWithLabel(`Error: ${message}`, managed.label, this.sessions.size));
231
296
  }
232
297
  }
233
298
  finally {
234
- this.messageInFlight = false;
299
+ managed.messageInFlight = false;
235
300
  }
236
301
  }
237
302
  // --- Session lifecycle ---
@@ -239,10 +304,15 @@ export class SignalBotDaemon {
239
304
  * Creates a new session with a fresh SignalSessionTransport.
240
305
  * Follows the same pattern as index.ts: create transport,
241
306
  * wire callbacks, create session, start transport.
307
+ * Returns the label of the new session.
242
308
  */
243
309
  async startNewSession() {
310
+ if (this.sessions.size >= this.maxConcurrentSessions) {
311
+ throw new Error(`Session limit reached (max ${this.maxConcurrentSessions}). Use /quit to end a session first.`);
312
+ }
313
+ const label = this.nextLabel++;
244
314
  const transport = new SignalSessionTransport(this);
245
- this.activeTransport = transport;
315
+ transport.sessionLabel = label;
246
316
  const config = loadConfig();
247
317
  const session = await createSession({
248
318
  config,
@@ -251,123 +321,282 @@ export class SignalBotDaemon {
251
321
  onEscalationExpired: transport.createEscalationExpiredHandler(),
252
322
  onDiagnostic: transport.createDiagnosticHandler(),
253
323
  });
254
- this.activeSession = session;
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;
255
334
  // Start the transport in the background. When it resolves
256
335
  // (session closed/budget exhausted), clean up.
257
336
  transport
258
337
  .run(session)
259
338
  .then(() => {
260
- // Transport exited - session is done
261
- if (this.activeTransport === transport) {
262
- this.activeSession = null;
263
- this.activeTransport = null;
339
+ // 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
+ }
264
345
  }
265
346
  })
266
347
  .catch((err) => {
267
- logger.error(`[Signal Daemon] Transport error: ${String(err)}`);
348
+ logger.error(`[Signal Daemon] Transport #${label} error: ${String(err)}`);
268
349
  });
269
- await this.sendSignalMessage('Started a new session.');
350
+ await this.sendSignalMessage(prefixWithLabel('Started a new session.', label, this.sessions.size));
351
+ return label;
352
+ }
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
+ }
270
370
  }
271
371
  /**
272
- * Ends the current session and cleans up. Awaits session.close()
273
- * to ensure resources are released (sandbox, MCP connections, etc.).
274
- * Guarded against re-entrance.
372
+ * Convenience method: ends the current session (if any).
275
373
  */
276
374
  async endCurrentSession() {
277
- if (!this.activeSession)
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;
278
386
  return;
279
- const session = this.activeSession;
280
- const transport = this.activeTransport;
281
- this.activeSession = null;
282
- this.activeTransport = null;
283
- this.pendingEscalationId = null;
284
- this.escalationResolving = false;
285
- // Close transport first (resolves run() promise), then session
286
- transport?.close();
287
- await session.close();
387
+ }
388
+ this.currentLabel = Math.max(...this.sessions.keys());
288
389
  }
289
390
  // --- Escalation handling ---
290
391
  /**
291
392
  * Checks if the message is an escalation reply.
292
393
  * Accepts: approve, deny, /approve, /deny (case-insensitive).
394
+ * Optionally with a session label: "approve #2", "/approve 2".
293
395
  *
294
- * Race condition prevention: escalationResolving flag prevents
295
- * concurrent replies. pendingEscalationId is cleared in .finally()
296
- * after async resolution completes, not before.
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.
297
399
  */
298
400
  handleEscalationReply(text) {
299
- if (!this.pendingEscalationId || !this.activeSession)
300
- return false;
301
401
  const normalized = text.trim().toLowerCase();
302
- const isApprove = normalized === 'approve' || normalized === '/approve';
303
- const isDeny = normalized === 'deny' || normalized === '/deny';
304
- if (!isApprove && !isDeny)
402
+ // Parse: approve, /approve, approve #2, /approve 2, deny, /deny, etc.
403
+ const match = normalized.match(/^\/?(approve|deny)(?:\s+#?(\d+))?$/);
404
+ if (!match)
405
+ return false;
406
+ const isApprove = match[1] === 'approve';
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
+ }
415
+ if (pending.length === 0)
305
416
  return false;
306
- if (this.escalationResolving) {
307
- this.sendSignalMessage('Escalation is being resolved, please wait...').catch(() => { });
417
+ // Determine target session
418
+ let target;
419
+ if (explicitLabel !== null) {
420
+ target = this.sessions.get(explicitLabel);
421
+ if (!target?.pendingEscalationId) {
422
+ this.sendSignalMessage(`Session #${explicitLabel} has no pending escalation.`).catch(() => { });
423
+ return true;
424
+ }
425
+ }
426
+ else if (pending.length === 1) {
427
+ target = pending[0];
428
+ }
429
+ else {
430
+ // Multiple pending - need disambiguation
431
+ const labels = pending.map((m) => `#${m.label}`).join(', ');
432
+ this.sendSignalMessage(`Multiple escalations pending (${labels}). Specify: \`approve #N\` or \`deny #N\``).catch(() => { });
433
+ return true;
434
+ }
435
+ if (target.escalationResolving) {
436
+ this.sendSignalMessage(prefixWithLabel('Escalation is being resolved, please wait...', target.label, this.sessions.size)).catch(() => { });
308
437
  return true;
309
438
  }
310
439
  const decision = isApprove ? 'approved' : 'denied';
311
- const escalationId = this.pendingEscalationId;
312
- this.escalationResolving = true;
313
- this.activeSession
440
+ const escalationId = target.pendingEscalationId;
441
+ const managed = target;
442
+ managed.escalationResolving = true;
443
+ managed.session
314
444
  .resolveEscalation(escalationId, decision)
315
445
  .then(() => {
316
- return this.sendSignalMessage(`Escalation ${decision}.`);
446
+ return this.sendSignalMessage(prefixWithLabel(`Escalation ${decision}.`, managed.label, this.sessions.size));
317
447
  })
318
448
  .catch((err) => {
319
449
  const msg = err instanceof Error ? err.message : String(err);
320
- this.sendSignalMessage(`Escalation error: ${msg}`).catch(() => { });
450
+ this.sendSignalMessage(prefixWithLabel(`Escalation error: ${msg}`, managed.label, this.sessions.size)).catch(() => { });
321
451
  })
322
452
  .finally(() => {
323
- this.escalationResolving = false;
324
- if (this.pendingEscalationId === escalationId) {
325
- this.pendingEscalationId = null;
453
+ managed.escalationResolving = false;
454
+ if (managed.pendingEscalationId === escalationId) {
455
+ managed.pendingEscalationId = null;
326
456
  }
327
457
  });
328
458
  return true;
329
459
  }
330
460
  /** Called by SignalSessionTransport when the session surfaces an escalation. */
331
- setPendingEscalation(escalationId) {
332
- this.pendingEscalationId = escalationId;
461
+ 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
+ }
333
470
  }
334
471
  /** Called by SignalSessionTransport when an escalation expires. */
335
- clearPendingEscalation() {
336
- this.pendingEscalationId = null;
472
+ clearPendingEscalation(label) {
473
+ const managed = this.sessions.get(label);
474
+ if (managed) {
475
+ managed.pendingEscalationId = null;
476
+ }
337
477
  }
338
478
  // --- Control commands ---
339
479
  handleControlCommand(text) {
340
- const trimmed = text.trim().toLowerCase();
341
- switch (trimmed) {
342
- case '/quit':
343
- case '/exit':
344
- case '/new':
345
- this.endCurrentSession()
346
- .then(() => {
347
- this.sendSignalMessage('Session ended. Send a message to start a new one.').catch(() => { });
348
- })
349
- .catch(() => { });
350
- return true;
351
- case '/budget': {
352
- if (!this.activeSession) {
353
- this.sendSignalMessage('No active session.').catch(() => { });
354
- return true;
480
+ const lower = text.trim().toLowerCase();
481
+ // /quit [N] or /exit [N]
482
+ const quitMatch = lower.match(/^\/(quit|exit)(?:\s+#?(\d+))?$/);
483
+ if (quitMatch) {
484
+ const explicitLabel = quitMatch[2] ? parseInt(quitMatch[2], 10) : null;
485
+ this.scheduleSessionOp(async () => {
486
+ const labelToEnd = explicitLabel ?? this.currentLabel;
487
+ if (labelToEnd === null) {
488
+ await this.sendSignalMessage('No active session.');
489
+ return;
355
490
  }
356
- const status = this.activeSession.getBudgetStatus();
357
- this.sendSignalMessage(formatBudgetMessage(status)).catch(() => { });
358
- return true;
359
- }
360
- case '/help':
361
- this.sendSignalMessage('Commands:\n' +
362
- ' /quit or /new - end current session\n' +
363
- ' /budget - show resource usage\n' +
491
+ if (!this.sessions.has(labelToEnd)) {
492
+ await this.sendSignalMessage(`No session #${labelToEnd}.`);
493
+ return;
494
+ }
495
+ const wasCurrent = labelToEnd === this.currentLabel;
496
+ try {
497
+ await this.endSession(labelToEnd);
498
+ }
499
+ catch (err) {
500
+ const msg = err instanceof Error ? err.message : String(err);
501
+ await this.sendSignalMessage(`Failed to end session #${labelToEnd}: ${msg}`);
502
+ return;
503
+ }
504
+ if (wasCurrent && this.sessions.size > 0 && this.currentLabel !== null) {
505
+ await this.sendSignalMessage(`Session #${labelToEnd} ended. Switched to #${this.currentLabel}.`);
506
+ }
507
+ else if (this.sessions.size > 0) {
508
+ await this.sendSignalMessage(`Session #${labelToEnd} ended.`);
509
+ }
510
+ else {
511
+ await this.sendSignalMessage('Session ended. Send a message to start a new one.');
512
+ }
513
+ });
514
+ return true;
515
+ }
516
+ // /new
517
+ if (lower === '/new') {
518
+ this.scheduleSessionOp(async () => {
519
+ try {
520
+ await this.startNewSession();
521
+ }
522
+ catch (err) {
523
+ const msg = err instanceof Error ? err.message : String(err);
524
+ await this.sendSignalMessage(msg);
525
+ }
526
+ });
527
+ return true;
528
+ }
529
+ // /sessions
530
+ if (lower === '/sessions') {
531
+ this.scheduleSessionOp(async () => {
532
+ const entries = Array.from(this.sessions.values(), (managed) => {
533
+ const budget = managed.session.getBudgetStatus();
534
+ const maxTokens = budget.limits.maxTotalTokens;
535
+ return {
536
+ label: managed.label,
537
+ turnCount: managed.session.getInfo().turnCount,
538
+ budgetPercent: maxTokens ? Math.round((budget.totalTokens / maxTokens) * 100) : 0,
539
+ };
540
+ });
541
+ await this.sendSignalMessage(formatSessionList(entries, this.currentLabel));
542
+ });
543
+ return true;
544
+ }
545
+ // /switch N
546
+ const switchMatch = lower.match(/^\/switch\s+#?(\d+)$/);
547
+ if (switchMatch) {
548
+ const label = parseInt(switchMatch[1], 10);
549
+ this.scheduleSessionOp(async () => {
550
+ if (!this.sessions.has(label)) {
551
+ await this.sendSignalMessage(`No session #${label}.`);
552
+ }
553
+ else {
554
+ this.currentLabel = label;
555
+ await this.sendSignalMessage(`Switched to session #${label}.`);
556
+ }
557
+ });
558
+ return true;
559
+ }
560
+ // /budget [N]
561
+ const budgetMatch = lower.match(/^\/budget(?:\s+#?(\d+))?$/);
562
+ if (budgetMatch) {
563
+ const explicitBudgetLabel = budgetMatch[1] ? parseInt(budgetMatch[1], 10) : null;
564
+ this.scheduleSessionOp(async () => {
565
+ const label = explicitBudgetLabel ?? this.currentLabel;
566
+ const managed = label !== null ? this.sessions.get(label) : undefined;
567
+ if (label === null) {
568
+ await this.sendSignalMessage('No active session.');
569
+ }
570
+ else if (!managed) {
571
+ await this.sendSignalMessage(`No session #${label}.`);
572
+ }
573
+ else {
574
+ const status = managed.session.getBudgetStatus();
575
+ await this.sendSignalMessage(prefixWithLabel(formatBudgetMessage(status), label, this.sessions.size));
576
+ }
577
+ });
578
+ return true;
579
+ }
580
+ // /help
581
+ if (lower === '/help') {
582
+ this.scheduleSessionOp(async () => {
583
+ const sessionLine = this.currentLabel !== null ? `Current session: #${this.currentLabel}` : 'No active session.';
584
+ await this.sendSignalMessage(sessionLine +
585
+ '\n\n' +
586
+ 'Commands:\n' +
587
+ ' /new - start a new session\n' +
588
+ ' /sessions - list active sessions\n' +
589
+ ' /switch N - switch to session #N\n' +
590
+ ' #N <message> - send to session #N without switching\n' +
591
+ ' /quit [N] - end session (current or #N)\n' +
592
+ ' /budget [N] - show resource usage\n' +
364
593
  ' /help - show this message\n' +
365
- ' approve or /approve - approve pending escalation\n' +
366
- ' deny or /deny - deny pending escalation').catch(() => { });
367
- return true;
368
- default:
369
- return false;
594
+ ' approve [#N] - approve pending escalation\n' +
595
+ ' deny [#N] - deny pending escalation');
596
+ });
597
+ return true;
370
598
  }
599
+ return false;
371
600
  }
372
601
  // --- Identity verification ---
373
602
  /**
@@ -482,16 +711,14 @@ export function parseSignalEnvelope(data) {
482
711
  const parsed = JSON.parse(data);
483
712
  if (typeof parsed !== 'object' || parsed === null)
484
713
  return null;
485
- // json-rpc mode wraps the envelope
486
714
  const record = parsed;
487
- const envelope = (record.envelope ?? parsed);
488
- return envelope;
715
+ // json-rpc mode wraps the envelope; fall back to the raw object
716
+ return (record.envelope ?? parsed);
489
717
  }
490
718
  catch {
491
719
  return null;
492
720
  }
493
721
  }
494
- /** Normalize a phone number by stripping spaces. */
495
722
  export function normalizePhoneNumber(number) {
496
723
  return number.replace(/\s+/g, '');
497
724
  }
@@ -501,4 +728,15 @@ export function isAuthorizedSender(envelope, recipientNumber) {
501
728
  return false;
502
729
  return normalizePhoneNumber(sender) === normalizePhoneNumber(recipientNumber);
503
730
  }
731
+ /**
732
+ * Parses an optional `#N` prefix from a message for one-shot session routing.
733
+ * Pattern: `#N ` at the start, where N is one or more digits, followed by a space.
734
+ * E.g., `#2 do something` -> { targetLabel: 2, messageText: 'do something' }
735
+ */
736
+ export function parseHashPrefix(text) {
737
+ const match = text.match(/^#(\d+) (.*)$/s);
738
+ if (!match)
739
+ return { targetLabel: null, messageText: text };
740
+ return { targetLabel: parseInt(match[1], 10), messageText: match[2] };
741
+ }
504
742
  //# sourceMappingURL=signal-bot-daemon.js.map