@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.
- package/README.md +219 -248
- package/dist/cli.js +10 -0
- package/dist/cli.js.map +1 -1
- package/dist/config/config-command.js +144 -6
- package/dist/config/config-command.js.map +1 -1
- package/dist/config/constitution-user-base.md +2 -0
- package/dist/config/first-start.js +77 -12
- package/dist/config/first-start.js.map +1 -1
- package/dist/config/generated/compiled-policy.json +199 -72
- package/dist/config/generated/tool-annotations.json +1018 -36
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/mcp-servers.json +13 -0
- package/dist/config/paths.d.ts +31 -0
- package/dist/config/paths.js +52 -2
- package/dist/config/paths.js.map +1 -1
- package/dist/config/types.d.ts +10 -0
- package/dist/config/user-config.d.ts +21 -3
- package/dist/config/user-config.js +34 -3
- package/dist/config/user-config.js.map +1 -1
- package/dist/docker/adapters/claude-code.d.ts +4 -0
- package/dist/docker/adapters/claude-code.js +96 -37
- package/dist/docker/adapters/claude-code.js.map +1 -1
- package/dist/docker/agent-adapter.d.ts +22 -13
- package/dist/docker/code-mode-proxy.d.ts +34 -0
- package/dist/docker/code-mode-proxy.js +137 -0
- package/dist/docker/code-mode-proxy.js.map +1 -0
- package/dist/docker/docker-agent-session.d.ts +22 -14
- package/dist/docker/docker-agent-session.js +144 -117
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-infrastructure.d.ts +51 -0
- package/dist/docker/docker-infrastructure.js +261 -0
- package/dist/docker/docker-infrastructure.js.map +1 -0
- package/dist/docker/docker-manager.js +27 -0
- package/dist/docker/docker-manager.js.map +1 -1
- package/dist/docker/mitm-proxy.js +4 -0
- package/dist/docker/mitm-proxy.js.map +1 -1
- package/dist/docker/oauth-credentials.d.ts +81 -0
- package/dist/docker/oauth-credentials.js +178 -0
- package/dist/docker/oauth-credentials.js.map +1 -0
- package/dist/docker/orientation.d.ts +3 -2
- package/dist/docker/orientation.js +4 -4
- package/dist/docker/orientation.js.map +1 -1
- package/dist/docker/provider-config.d.ts +3 -0
- package/dist/docker/provider-config.js +28 -0
- package/dist/docker/provider-config.js.map +1 -1
- package/dist/docker/pty-session.d.ts +27 -0
- package/dist/docker/pty-session.js +420 -0
- package/dist/docker/pty-session.js.map +1 -0
- package/dist/docker/pty-types.d.ts +24 -0
- package/dist/docker/pty-types.js +12 -0
- package/dist/docker/pty-types.js.map +1 -0
- package/dist/docker/types.d.ts +18 -2
- package/dist/escalation/escalation-watcher.d.ts +44 -0
- package/dist/escalation/escalation-watcher.js +92 -0
- package/dist/escalation/escalation-watcher.js.map +1 -0
- package/dist/escalation/listener-command.d.ts +10 -0
- package/dist/escalation/listener-command.js +407 -0
- package/dist/escalation/listener-command.js.map +1 -0
- package/dist/escalation/listener-state.d.ts +49 -0
- package/dist/escalation/listener-state.js +104 -0
- package/dist/escalation/listener-state.js.map +1 -0
- package/dist/escalation/session-registry.d.ts +17 -0
- package/dist/escalation/session-registry.js +98 -0
- package/dist/escalation/session-registry.js.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.js +2 -3
- package/dist/logger.js.map +1 -1
- package/dist/pipeline/annotate.js +35 -15
- package/dist/pipeline/annotate.js.map +1 -1
- package/dist/pipeline/constitution-customizer.d.ts +2 -1
- package/dist/pipeline/constitution-customizer.js +36 -3
- package/dist/pipeline/constitution-customizer.js.map +1 -1
- package/dist/pipeline/github-identity.d.ts +38 -0
- package/dist/pipeline/github-identity.js +64 -0
- package/dist/pipeline/github-identity.js.map +1 -0
- package/dist/pipeline/handwritten-scenarios.js +34 -0
- package/dist/pipeline/handwritten-scenarios.js.map +1 -1
- package/dist/sandbox/index.d.ts +25 -1
- package/dist/sandbox/index.js +90 -12
- package/dist/sandbox/index.js.map +1 -1
- package/dist/servers/fetch-server.d.ts +6 -1
- package/dist/servers/fetch-server.js +26 -14
- package/dist/servers/fetch-server.js.map +1 -1
- package/dist/session/agent-session.js +9 -5
- package/dist/session/agent-session.js.map +1 -1
- package/dist/session/base-transport.d.ts +19 -0
- package/dist/session/base-transport.js +36 -0
- package/dist/session/base-transport.js.map +1 -0
- package/dist/session/cli-transport.d.ts +3 -3
- package/dist/session/cli-transport.js +7 -4
- package/dist/session/cli-transport.js.map +1 -1
- package/dist/session/index.js +24 -106
- package/dist/session/index.js.map +1 -1
- package/dist/session/interaction-log.d.ts +13 -0
- package/dist/session/interaction-log.js +28 -0
- package/dist/session/interaction-log.js.map +1 -0
- package/dist/session/preflight.d.ts +3 -0
- package/dist/session/preflight.js +45 -18
- package/dist/session/preflight.js.map +1 -1
- package/dist/session/prompts.d.ts +9 -3
- package/dist/session/prompts.js +28 -10
- package/dist/session/prompts.js.map +1 -1
- package/dist/session/types.d.ts +2 -0
- package/dist/signal/bot-command.js +5 -0
- package/dist/signal/bot-command.js.map +1 -1
- package/dist/signal/format.d.ts +21 -1
- package/dist/signal/format.js +41 -3
- package/dist/signal/format.js.map +1 -1
- package/dist/signal/signal-bot-daemon.d.ts +53 -18
- package/dist/signal/signal-bot-daemon.js +344 -106
- package/dist/signal/signal-bot-daemon.js.map +1 -1
- package/dist/signal/signal-config.d.ts +4 -0
- package/dist/signal/signal-config.js +2 -0
- package/dist/signal/signal-config.js.map +1 -1
- package/dist/signal/signal-transport.d.ts +13 -7
- package/dist/signal/signal-transport.js +36 -12
- package/dist/signal/signal-transport.js.map +1 -1
- package/dist/trusted-process/audit-log.d.ts +6 -1
- package/dist/trusted-process/audit-log.js +22 -2
- package/dist/trusted-process/audit-log.js.map +1 -1
- package/dist/trusted-process/audit-redactor.d.ts +25 -0
- package/dist/trusted-process/audit-redactor.js +121 -0
- package/dist/trusted-process/audit-redactor.js.map +1 -0
- package/dist/trusted-process/escalation.d.ts +1 -1
- package/dist/trusted-process/escalation.js +6 -1
- package/dist/trusted-process/escalation.js.map +1 -1
- package/dist/trusted-process/index.d.ts +2 -1
- package/dist/trusted-process/index.js +24 -6
- package/dist/trusted-process/index.js.map +1 -1
- package/dist/trusted-process/mcp-client-manager.js +14 -2
- package/dist/trusted-process/mcp-client-manager.js.map +1 -1
- package/dist/trusted-process/mcp-error-utils.d.ts +11 -0
- package/dist/trusted-process/mcp-error-utils.js +50 -0
- package/dist/trusted-process/mcp-error-utils.js.map +1 -0
- package/dist/trusted-process/mcp-proxy-server.d.ts +115 -1
- package/dist/trusted-process/mcp-proxy-server.js +318 -267
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/policy-engine.d.ts +3 -1
- package/dist/trusted-process/policy-engine.js +17 -6
- package/dist/trusted-process/policy-engine.js.map +1 -1
- package/dist/trusted-process/sandbox-integration.d.ts +16 -4
- package/dist/trusted-process/sandbox-integration.js +14 -8
- package/dist/trusted-process/sandbox-integration.js.map +1 -1
- package/dist/trusted-process/server-context.d.ts +21 -0
- package/dist/trusted-process/server-context.js +42 -0
- package/dist/trusted-process/server-context.js.map +1 -0
- package/dist/types/argument-roles.d.ts +12 -2
- package/dist/types/argument-roles.js +37 -4
- package/dist/types/argument-roles.js.map +1 -1
- package/docker/Dockerfile.claude-code +8 -3
- package/docker/entrypoint-claude-code.sh +64 -4
- package/package.json +19 -9
- package/src/config/constitution-user-base.md +2 -0
- package/src/config/generated/compiled-policy.json +199 -72
- package/src/config/generated/tool-annotations.json +1018 -36
- package/src/config/mcp-servers.json +13 -0
- package/dist/docker/managed-proxy.d.ts +0 -31
- package/dist/docker/managed-proxy.js +0 -265
- 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
|
-
* -
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
259
|
+
if (this.currentLabel === null)
|
|
260
|
+
return;
|
|
261
|
+
const managed = this.sessions.get(this.currentLabel);
|
|
262
|
+
if (!managed)
|
|
212
263
|
return;
|
|
213
|
-
this.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
261
|
-
if (this.
|
|
262
|
-
this.
|
|
263
|
-
this.
|
|
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
|
-
*
|
|
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 (
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
-
|
|
303
|
-
const
|
|
304
|
-
if (!
|
|
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
|
-
|
|
307
|
-
|
|
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 =
|
|
312
|
-
|
|
313
|
-
|
|
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}
|
|
450
|
+
this.sendSignalMessage(prefixWithLabel(`Escalation error: ${msg}`, managed.label, this.sessions.size)).catch(() => { });
|
|
321
451
|
})
|
|
322
452
|
.finally(() => {
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
366
|
-
' deny
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|