@provos/ironcurtain 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -66
- package/dist/cli-help.d.ts +63 -0
- package/dist/cli-help.js +102 -0
- package/dist/cli-help.js.map +1 -0
- package/dist/cli.js +78 -52
- package/dist/cli.js.map +1 -1
- package/dist/config/config-command.js +136 -2
- package/dist/config/config-command.js.map +1 -1
- package/dist/config/generated/tool-annotations.json +487 -338
- package/dist/config/index.d.ts +21 -1
- package/dist/config/index.js +12 -4
- package/dist/config/index.js.map +1 -1
- package/dist/config/mcp-servers.json +2 -8
- package/dist/config/paths.d.ts +30 -0
- package/dist/config/paths.js +58 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/tool-description-hints.json +15 -0
- package/dist/config/types.d.ts +13 -0
- package/dist/config/user-config.d.ts +21 -0
- package/dist/config/user-config.js +8 -0
- package/dist/config/user-config.js.map +1 -1
- package/dist/cron/compile-task-policy.d.ts +17 -0
- package/dist/cron/compile-task-policy.js +39 -0
- package/dist/cron/compile-task-policy.js.map +1 -0
- package/dist/cron/cron-scheduler.d.ts +65 -0
- package/dist/cron/cron-scheduler.js +184 -0
- package/dist/cron/cron-scheduler.js.map +1 -0
- package/dist/cron/file-lock.d.ts +41 -0
- package/dist/cron/file-lock.js +156 -0
- package/dist/cron/file-lock.js.map +1 -0
- package/dist/cron/format-utils.d.ts +18 -0
- package/dist/cron/format-utils.js +145 -0
- package/dist/cron/format-utils.js.map +1 -0
- package/dist/cron/git-sync.d.ts +22 -0
- package/dist/cron/git-sync.js +121 -0
- package/dist/cron/git-sync.js.map +1 -0
- package/dist/cron/headless-transport.d.ts +27 -0
- package/dist/cron/headless-transport.js +43 -0
- package/dist/cron/headless-transport.js.map +1 -0
- package/dist/cron/job-commands.d.ts +39 -0
- package/dist/cron/job-commands.js +522 -0
- package/dist/cron/job-commands.js.map +1 -0
- package/dist/cron/job-store.d.ts +35 -0
- package/dist/cron/job-store.js +111 -0
- package/dist/cron/job-store.js.map +1 -0
- package/dist/cron/types.d.ts +127 -0
- package/dist/cron/types.js +25 -0
- package/dist/cron/types.js.map +1 -0
- package/dist/daemon/control-socket.d.ts +127 -0
- package/dist/daemon/control-socket.js +261 -0
- package/dist/daemon/control-socket.js.map +1 -0
- package/dist/daemon/daemon-command.d.ts +13 -0
- package/dist/daemon/daemon-command.js +230 -0
- package/dist/daemon/daemon-command.js.map +1 -0
- package/dist/daemon/ironcurtain-daemon.d.ts +90 -0
- package/dist/daemon/ironcurtain-daemon.js +548 -0
- package/dist/daemon/ironcurtain-daemon.js.map +1 -0
- package/dist/docker/adapters/claude-code.js +8 -48
- package/dist/docker/adapters/claude-code.js.map +1 -1
- package/dist/docker/adapters/goose.d.ts +58 -0
- package/dist/docker/adapters/goose.js +250 -0
- package/dist/docker/adapters/goose.js.map +1 -0
- package/dist/docker/adapters/shared-scripts.d.ts +29 -0
- package/dist/docker/adapters/shared-scripts.js +80 -0
- package/dist/docker/adapters/shared-scripts.js.map +1 -0
- package/dist/docker/agent-adapter.d.ts +12 -0
- package/dist/docker/agent-adapter.js.map +1 -1
- package/dist/docker/agent-registry.d.ts +6 -1
- package/dist/docker/agent-registry.js +10 -1
- package/dist/docker/agent-registry.js.map +1 -1
- package/dist/docker/docker-agent-session.d.ts +2 -25
- package/dist/docker/docker-agent-session.js +7 -106
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-infrastructure.d.ts +8 -0
- package/dist/docker/docker-infrastructure.js +22 -14
- package/dist/docker/docker-infrastructure.js.map +1 -1
- package/dist/docker/mitm-proxy.d.ts +5 -2
- package/dist/docker/mitm-proxy.js +169 -46
- package/dist/docker/mitm-proxy.js.map +1 -1
- package/dist/docker/oauth-credentials.d.ts +15 -2
- package/dist/docker/oauth-credentials.js +92 -8
- package/dist/docker/oauth-credentials.js.map +1 -1
- package/dist/docker/oauth-token-manager.d.ts +80 -0
- package/dist/docker/oauth-token-manager.js +133 -0
- package/dist/docker/oauth-token-manager.js.map +1 -0
- package/dist/docker/pty-session.d.ts +2 -0
- package/dist/docker/pty-session.js +17 -8
- package/dist/docker/pty-session.js.map +1 -1
- package/dist/escalation/escalation-watcher.d.ts +2 -0
- package/dist/escalation/escalation-watcher.js +5 -0
- package/dist/escalation/escalation-watcher.js.map +1 -1
- package/dist/escalation/listener-command.js +9 -58
- package/dist/escalation/listener-command.js.map +1 -1
- package/dist/escalation/listener-lock.d.ts +20 -0
- package/dist/escalation/listener-lock.js +69 -0
- package/dist/escalation/listener-lock.js.map +1 -0
- package/dist/escalation/session-registry.js +1 -14
- package/dist/escalation/session-registry.js.map +1 -1
- package/dist/index.js +51 -2
- package/dist/index.js.map +1 -1
- package/dist/mux/mux-app.d.ts +24 -0
- package/dist/mux/mux-app.js +450 -0
- package/dist/mux/mux-app.js.map +1 -0
- package/dist/mux/mux-command.d.ts +7 -0
- package/dist/mux/mux-command.js +104 -0
- package/dist/mux/mux-command.js.map +1 -0
- package/dist/mux/mux-escalation-manager.d.ts +48 -0
- package/dist/mux/mux-escalation-manager.js +162 -0
- package/dist/mux/mux-escalation-manager.js.map +1 -0
- package/dist/mux/mux-input-handler.d.ts +54 -0
- package/dist/mux/mux-input-handler.js +512 -0
- package/dist/mux/mux-input-handler.js.map +1 -0
- package/dist/mux/mux-renderer.d.ts +106 -0
- package/dist/mux/mux-renderer.js +850 -0
- package/dist/mux/mux-renderer.js.map +1 -0
- package/dist/mux/mux-splash.d.ts +17 -0
- package/dist/mux/mux-splash.js +323 -0
- package/dist/mux/mux-splash.js.map +1 -0
- package/dist/mux/paste-interceptor.d.ts +22 -0
- package/dist/mux/paste-interceptor.js +143 -0
- package/dist/mux/paste-interceptor.js.map +1 -0
- package/dist/mux/pty-bridge.d.ts +79 -0
- package/dist/mux/pty-bridge.js +151 -0
- package/dist/mux/pty-bridge.js.map +1 -0
- package/dist/mux/trusted-input.d.ts +27 -0
- package/dist/mux/trusted-input.js +27 -0
- package/dist/mux/trusted-input.js.map +1 -0
- package/dist/mux/types.d.ts +99 -0
- package/dist/mux/types.js +57 -0
- package/dist/mux/types.js.map +1 -0
- package/dist/pipeline/annotate.js +2 -1
- package/dist/pipeline/annotate.js.map +1 -1
- package/dist/pipeline/compile.d.ts +4 -34
- package/dist/pipeline/compile.js +29 -568
- package/dist/pipeline/compile.js.map +1 -1
- package/dist/pipeline/constitution-compiler.js +1 -1
- package/dist/pipeline/mcp-connections.d.ts +16 -0
- package/dist/pipeline/mcp-connections.js +61 -0
- package/dist/pipeline/mcp-connections.js.map +1 -0
- package/dist/pipeline/pipeline-runner.d.ts +86 -0
- package/dist/pipeline/pipeline-runner.js +554 -0
- package/dist/pipeline/pipeline-runner.js.map +1 -0
- package/dist/pipeline/pipeline-shared.d.ts +29 -2
- package/dist/pipeline/pipeline-shared.js +68 -12
- package/dist/pipeline/pipeline-shared.js.map +1 -1
- package/dist/pipeline/refresh-lists.js +38 -12
- package/dist/pipeline/refresh-lists.js.map +1 -1
- package/dist/pipeline/scenario-generator.js +2 -1
- package/dist/pipeline/scenario-generator.js.map +1 -1
- package/dist/pipeline/tool-annotator.d.ts +3 -3
- package/dist/pipeline/tool-annotator.js +96 -4
- package/dist/pipeline/tool-annotator.js.map +1 -1
- package/dist/pipeline/types.d.ts +52 -0
- package/dist/sandbox/index.js +40 -1
- package/dist/sandbox/index.js.map +1 -1
- package/dist/session/agent-session.d.ts +3 -0
- package/dist/session/agent-session.js +13 -1
- package/dist/session/agent-session.js.map +1 -1
- package/dist/session/index.js +65 -9
- package/dist/session/index.js.map +1 -1
- package/dist/session/preflight.js +26 -6
- package/dist/session/preflight.js.map +1 -1
- package/dist/session/prompts.d.ts +14 -0
- package/dist/session/prompts.js +39 -2
- package/dist/session/prompts.js.map +1 -1
- package/dist/session/session-manager.d.ts +105 -0
- package/dist/session/session-manager.js +134 -0
- package/dist/session/session-manager.js.map +1 -0
- package/dist/session/types.d.ts +33 -0
- package/dist/session/workspace-validation.d.ts +19 -0
- package/dist/session/workspace-validation.js +66 -0
- package/dist/session/workspace-validation.js.map +1 -0
- package/dist/signal/bot-command.d.ts +2 -0
- package/dist/signal/bot-command.js +8 -0
- package/dist/signal/bot-command.js.map +1 -1
- package/dist/signal/format.d.ts +27 -0
- package/dist/signal/format.js +59 -0
- package/dist/signal/format.js.map +1 -1
- package/dist/signal/signal-bot-daemon.d.ts +27 -25
- package/dist/signal/signal-bot-daemon.js +145 -130
- package/dist/signal/signal-bot-daemon.js.map +1 -1
- package/dist/signal/signal-container.js +63 -19
- package/dist/signal/signal-container.js.map +1 -1
- package/dist/signal/signal-transport.js +3 -8
- package/dist/signal/signal-transport.js.map +1 -1
- package/dist/trusted-process/auto-approver.d.ts +13 -4
- package/dist/trusted-process/auto-approver.js +12 -5
- package/dist/trusted-process/auto-approver.js.map +1 -1
- package/dist/trusted-process/domain-utils.d.ts +38 -4
- package/dist/trusted-process/domain-utils.js +118 -11
- package/dist/trusted-process/domain-utils.js.map +1 -1
- package/dist/trusted-process/escalation.js +5 -0
- package/dist/trusted-process/escalation.js.map +1 -1
- package/dist/trusted-process/index.js +6 -2
- package/dist/trusted-process/index.js.map +1 -1
- package/dist/trusted-process/mcp-client-manager.js +11 -5
- package/dist/trusted-process/mcp-client-manager.js.map +1 -1
- package/dist/trusted-process/mcp-proxy-server.d.ts +2 -0
- package/dist/trusted-process/mcp-proxy-server.js +98 -32
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/permissive-output-validator.d.ts +23 -0
- package/dist/trusted-process/permissive-output-validator.js +10 -0
- package/dist/trusted-process/permissive-output-validator.js.map +1 -0
- package/dist/trusted-process/policy-engine.d.ts +27 -4
- package/dist/trusted-process/policy-engine.js +152 -57
- package/dist/trusted-process/policy-engine.js.map +1 -1
- package/dist/trusted-process/tool-description-hints.d.ts +19 -0
- package/dist/trusted-process/tool-description-hints.js +53 -0
- package/dist/trusted-process/tool-description-hints.js.map +1 -0
- package/dist/types/argument-roles.d.ts +33 -1
- package/dist/types/argument-roles.js +129 -1
- package/dist/types/argument-roles.js.map +1 -1
- package/docker/Dockerfile.goose +34 -0
- package/docker/entrypoint-goose.sh +30 -0
- package/package.json +6 -1
- package/src/config/generated/tool-annotations.json +487 -338
- 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
|
-
*
|
|
70
|
-
*
|
|
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
|
|
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
|
-
|
|
77
|
-
//
|
|
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
|
-
*
|
|
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
|
|
94
|
-
await Promise.allSettled(
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
298
|
+
const currentLabel = this.sessionManager.currentLabel;
|
|
299
|
+
if (currentLabel === null)
|
|
260
300
|
return;
|
|
261
|
-
const managed = this.
|
|
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=${
|
|
273
|
-
|
|
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,
|
|
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
|
|
325
|
+
const response = await transport.forwardMessage(text);
|
|
281
326
|
const styledText = markdownToSignal(response);
|
|
282
|
-
await this.sendSignalMessage(prefixWithLabel(styledText, managed.label, this.
|
|
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.
|
|
291
|
-
await this.
|
|
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.
|
|
340
|
+
await this.sendSignalMessage(prefixWithLabel(`Error: ${message}`, managed.label, this.sessionManager.size));
|
|
296
341
|
}
|
|
297
342
|
}
|
|
298
343
|
finally {
|
|
@@ -301,18 +346,18 @@ export class SignalBotDaemon {
|
|
|
301
346
|
}
|
|
302
347
|
// --- Session lifecycle ---
|
|
303
348
|
/**
|
|
304
|
-
* Creates a new session with a fresh SignalSessionTransport.
|
|
349
|
+
* Creates a new Signal session with a fresh SignalSessionTransport.
|
|
305
350
|
* Follows the same pattern as index.ts: create transport,
|
|
306
351
|
* wire callbacks, create session, start transport.
|
|
307
352
|
* Returns the label of the new session.
|
|
308
353
|
*/
|
|
309
354
|
async startNewSession() {
|
|
310
|
-
|
|
355
|
+
// Count only Signal sessions toward the concurrency limit
|
|
356
|
+
const signalSessionCount = this.sessionManager.byKind('signal').length;
|
|
357
|
+
if (signalSessionCount >= this.maxConcurrentSessions) {
|
|
311
358
|
throw new Error(`Session limit reached (max ${this.maxConcurrentSessions}). Use /quit to end a session first.`);
|
|
312
359
|
}
|
|
313
|
-
const label = this.nextLabel++;
|
|
314
360
|
const transport = new SignalSessionTransport(this);
|
|
315
|
-
transport.sessionLabel = label;
|
|
316
361
|
const config = loadConfig();
|
|
317
362
|
const session = await createSession({
|
|
318
363
|
config,
|
|
@@ -321,81 +366,36 @@ export class SignalBotDaemon {
|
|
|
321
366
|
onEscalationExpired: transport.createEscalationExpiredHandler(),
|
|
322
367
|
onDiagnostic: transport.createDiagnosticHandler(),
|
|
323
368
|
});
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
session,
|
|
327
|
-
transport,
|
|
328
|
-
messageInFlight: false,
|
|
329
|
-
pendingEscalationId: null,
|
|
330
|
-
escalationResolving: false,
|
|
331
|
-
};
|
|
332
|
-
this.sessions.set(label, managed);
|
|
333
|
-
this.currentLabel = label;
|
|
369
|
+
const label = this.sessionManager.register(session, transport, { kind: 'signal' });
|
|
370
|
+
transport.sessionLabel = label;
|
|
334
371
|
// Start the transport in the background. When it resolves
|
|
335
372
|
// (session closed/budget exhausted), clean up.
|
|
336
373
|
transport
|
|
337
374
|
.run(session)
|
|
338
375
|
.then(() => {
|
|
339
376
|
// Transport exited - remove session if still present
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
377
|
+
const managed = this.sessionManager.get(label);
|
|
378
|
+
if (managed) {
|
|
379
|
+
this.sessionManager.end(label).catch((err) => {
|
|
380
|
+
logger.error(`[Signal Daemon] Failed to clean up session #${label}: ${String(err)}`);
|
|
381
|
+
});
|
|
345
382
|
}
|
|
346
383
|
})
|
|
347
384
|
.catch((err) => {
|
|
348
385
|
logger.error(`[Signal Daemon] Transport #${label} error: ${String(err)}`);
|
|
349
386
|
});
|
|
350
|
-
await this.sendSignalMessage(prefixWithLabel('Started a new session.', label, this.
|
|
387
|
+
await this.sendSignalMessage(prefixWithLabel('Started a new session.', label, this.sessionManager.size));
|
|
351
388
|
return label;
|
|
352
389
|
}
|
|
353
|
-
/**
|
|
354
|
-
* Ends a specific session by label and cleans up.
|
|
355
|
-
* If it was the current session, auto-switches to the most recent remaining.
|
|
356
|
-
*/
|
|
357
|
-
async endSession(label) {
|
|
358
|
-
const managed = this.sessions.get(label);
|
|
359
|
-
if (!managed)
|
|
360
|
-
return;
|
|
361
|
-
// Close transport first (resolves run() promise), then session.
|
|
362
|
-
// Only remove from the map after successful close so the session
|
|
363
|
-
// remains trackable (and retryable) if session.close() fails.
|
|
364
|
-
managed.transport.close();
|
|
365
|
-
await managed.session.close();
|
|
366
|
-
this.sessions.delete(label);
|
|
367
|
-
if (this.currentLabel === label) {
|
|
368
|
-
this.autoSwitchCurrent();
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
/**
|
|
372
|
-
* Convenience method: ends the current session (if any).
|
|
373
|
-
*/
|
|
374
|
-
async endCurrentSession() {
|
|
375
|
-
if (this.currentLabel === null)
|
|
376
|
-
return;
|
|
377
|
-
await this.endSession(this.currentLabel);
|
|
378
|
-
}
|
|
379
|
-
/**
|
|
380
|
-
* Auto-switches currentLabel to the highest remaining label (most recent),
|
|
381
|
-
* or null if no sessions remain.
|
|
382
|
-
*/
|
|
383
|
-
autoSwitchCurrent() {
|
|
384
|
-
if (this.sessions.size === 0) {
|
|
385
|
-
this.currentLabel = null;
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
this.currentLabel = Math.max(...this.sessions.keys());
|
|
389
|
-
}
|
|
390
390
|
// --- Escalation handling ---
|
|
391
391
|
/**
|
|
392
392
|
* Checks if the message is an escalation reply.
|
|
393
393
|
* Accepts: approve, deny, /approve, /deny (case-insensitive).
|
|
394
394
|
* Optionally with a session label: "approve #2", "/approve 2".
|
|
395
395
|
*
|
|
396
|
-
* Routes to the session with a pending escalation.
|
|
397
|
-
*
|
|
398
|
-
*
|
|
396
|
+
* Routes to the session with a pending escalation.
|
|
397
|
+
* Works across ALL session types (signal + cron) so that
|
|
398
|
+
* `approve #3` from Signal can approve a cron job's escalation.
|
|
399
399
|
*/
|
|
400
400
|
handleEscalationReply(text) {
|
|
401
401
|
const normalized = text.trim().toLowerCase();
|
|
@@ -405,19 +405,14 @@ export class SignalBotDaemon {
|
|
|
405
405
|
return false;
|
|
406
406
|
const isApprove = match[1] === 'approve';
|
|
407
407
|
const explicitLabel = match[2] ? parseInt(match[2], 10) : null;
|
|
408
|
-
// Find sessions with pending escalations
|
|
409
|
-
const pending =
|
|
410
|
-
for (const managed of this.sessions.values()) {
|
|
411
|
-
if (managed.pendingEscalationId) {
|
|
412
|
-
pending.push(managed);
|
|
413
|
-
}
|
|
414
|
-
}
|
|
408
|
+
// Find sessions with pending escalations (across all session types)
|
|
409
|
+
const pending = this.sessionManager.withPendingEscalation();
|
|
415
410
|
if (pending.length === 0)
|
|
416
411
|
return false;
|
|
417
412
|
// Determine target session
|
|
418
413
|
let target;
|
|
419
414
|
if (explicitLabel !== null) {
|
|
420
|
-
target = this.
|
|
415
|
+
target = this.sessionManager.get(explicitLabel);
|
|
421
416
|
if (!target?.pendingEscalationId) {
|
|
422
417
|
this.sendSignalMessage(`Session #${explicitLabel} has no pending escalation.`).catch(() => { });
|
|
423
418
|
return true;
|
|
@@ -433,7 +428,7 @@ export class SignalBotDaemon {
|
|
|
433
428
|
return true;
|
|
434
429
|
}
|
|
435
430
|
if (target.escalationResolving) {
|
|
436
|
-
this.sendSignalMessage(prefixWithLabel('Escalation is being resolved, please wait...', target.label, this.
|
|
431
|
+
this.sendSignalMessage(prefixWithLabel('Escalation is being resolved, please wait...', target.label, this.sessionManager.size)).catch(() => { });
|
|
437
432
|
return true;
|
|
438
433
|
}
|
|
439
434
|
const decision = isApprove ? 'approved' : 'denied';
|
|
@@ -443,11 +438,11 @@ export class SignalBotDaemon {
|
|
|
443
438
|
managed.session
|
|
444
439
|
.resolveEscalation(escalationId, decision)
|
|
445
440
|
.then(() => {
|
|
446
|
-
return this.sendSignalMessage(prefixWithLabel(`Escalation ${decision}.`, managed.label, this.
|
|
441
|
+
return this.sendSignalMessage(prefixWithLabel(`Escalation ${decision}.`, managed.label, this.sessionManager.size));
|
|
447
442
|
})
|
|
448
443
|
.catch((err) => {
|
|
449
444
|
const msg = err instanceof Error ? err.message : String(err);
|
|
450
|
-
this.sendSignalMessage(prefixWithLabel(`Escalation error: ${msg}`, managed.label, this.
|
|
445
|
+
this.sendSignalMessage(prefixWithLabel(`Escalation error: ${msg}`, managed.label, this.sessionManager.size)).catch(() => { });
|
|
451
446
|
})
|
|
452
447
|
.finally(() => {
|
|
453
448
|
managed.escalationResolving = false;
|
|
@@ -459,21 +454,12 @@ export class SignalBotDaemon {
|
|
|
459
454
|
}
|
|
460
455
|
/** Called by SignalSessionTransport when the session surfaces an escalation. */
|
|
461
456
|
setPendingEscalation(label, escalationId) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
managed.pendingEscalationId = escalationId;
|
|
465
|
-
logger.info(`[Signal Daemon] Escalation ${escalationId} set on session #${label}`);
|
|
466
|
-
}
|
|
467
|
-
else {
|
|
468
|
-
logger.error(`[Signal Daemon] setPendingEscalation: no session #${label} in map (keys: ${[...this.sessions.keys()].join(',')})`);
|
|
469
|
-
}
|
|
457
|
+
this.sessionManager.setPendingEscalation(label, escalationId);
|
|
458
|
+
logger.info(`[Signal Daemon] Escalation ${escalationId} set on session #${label}`);
|
|
470
459
|
}
|
|
471
460
|
/** Called by SignalSessionTransport when an escalation expires. */
|
|
472
461
|
clearPendingEscalation(label) {
|
|
473
|
-
|
|
474
|
-
if (managed) {
|
|
475
|
-
managed.pendingEscalationId = null;
|
|
476
|
-
}
|
|
462
|
+
this.sessionManager.clearPendingEscalation(label);
|
|
477
463
|
}
|
|
478
464
|
// --- Control commands ---
|
|
479
465
|
handleControlCommand(text) {
|
|
@@ -483,28 +469,28 @@ export class SignalBotDaemon {
|
|
|
483
469
|
if (quitMatch) {
|
|
484
470
|
const explicitLabel = quitMatch[2] ? parseInt(quitMatch[2], 10) : null;
|
|
485
471
|
this.scheduleSessionOp(async () => {
|
|
486
|
-
const labelToEnd = explicitLabel ?? this.currentLabel;
|
|
472
|
+
const labelToEnd = explicitLabel ?? this.sessionManager.currentLabel;
|
|
487
473
|
if (labelToEnd === null) {
|
|
488
474
|
await this.sendSignalMessage('No active session.');
|
|
489
475
|
return;
|
|
490
476
|
}
|
|
491
|
-
if (!this.
|
|
477
|
+
if (!this.sessionManager.get(labelToEnd)) {
|
|
492
478
|
await this.sendSignalMessage(`No session #${labelToEnd}.`);
|
|
493
479
|
return;
|
|
494
480
|
}
|
|
495
|
-
const wasCurrent = labelToEnd === this.currentLabel;
|
|
481
|
+
const wasCurrent = labelToEnd === this.sessionManager.currentLabel;
|
|
496
482
|
try {
|
|
497
|
-
await this.
|
|
483
|
+
await this.sessionManager.end(labelToEnd);
|
|
498
484
|
}
|
|
499
485
|
catch (err) {
|
|
500
486
|
const msg = err instanceof Error ? err.message : String(err);
|
|
501
487
|
await this.sendSignalMessage(`Failed to end session #${labelToEnd}: ${msg}`);
|
|
502
488
|
return;
|
|
503
489
|
}
|
|
504
|
-
if (wasCurrent && this.
|
|
505
|
-
await this.sendSignalMessage(`Session #${labelToEnd} ended. Switched to #${this.currentLabel}.`);
|
|
490
|
+
if (wasCurrent && this.sessionManager.size > 0 && this.sessionManager.currentLabel !== null) {
|
|
491
|
+
await this.sendSignalMessage(`Session #${labelToEnd} ended. Switched to #${this.sessionManager.currentLabel}.`);
|
|
506
492
|
}
|
|
507
|
-
else if (this.
|
|
493
|
+
else if (this.sessionManager.size > 0) {
|
|
508
494
|
await this.sendSignalMessage(`Session #${labelToEnd} ended.`);
|
|
509
495
|
}
|
|
510
496
|
else {
|
|
@@ -529,7 +515,8 @@ export class SignalBotDaemon {
|
|
|
529
515
|
// /sessions
|
|
530
516
|
if (lower === '/sessions') {
|
|
531
517
|
this.scheduleSessionOp(async () => {
|
|
532
|
-
const
|
|
518
|
+
const allSessions = this.sessionManager.all();
|
|
519
|
+
const entries = allSessions.map((managed) => {
|
|
533
520
|
const budget = managed.session.getBudgetStatus();
|
|
534
521
|
const maxTokens = budget.limits.maxTotalTokens;
|
|
535
522
|
return {
|
|
@@ -538,20 +525,24 @@ export class SignalBotDaemon {
|
|
|
538
525
|
budgetPercent: maxTokens ? Math.round((budget.totalTokens / maxTokens) * 100) : 0,
|
|
539
526
|
};
|
|
540
527
|
});
|
|
541
|
-
await this.sendSignalMessage(formatSessionList(entries, this.currentLabel));
|
|
528
|
+
await this.sendSignalMessage(formatSessionList(entries, this.sessionManager.currentLabel));
|
|
542
529
|
});
|
|
543
530
|
return true;
|
|
544
531
|
}
|
|
545
|
-
// /switch N
|
|
532
|
+
// /switch N - only switch to Signal sessions
|
|
546
533
|
const switchMatch = lower.match(/^\/switch\s+#?(\d+)$/);
|
|
547
534
|
if (switchMatch) {
|
|
548
535
|
const label = parseInt(switchMatch[1], 10);
|
|
549
536
|
this.scheduleSessionOp(async () => {
|
|
550
|
-
|
|
537
|
+
const managed = this.sessionManager.get(label);
|
|
538
|
+
if (!managed) {
|
|
551
539
|
await this.sendSignalMessage(`No session #${label}.`);
|
|
552
540
|
}
|
|
541
|
+
else if (managed.source.kind !== 'signal') {
|
|
542
|
+
await this.sendSignalMessage(`Session #${label} is a ${managed.source.kind} session and cannot be switched to.`);
|
|
543
|
+
}
|
|
553
544
|
else {
|
|
554
|
-
this.currentLabel = label;
|
|
545
|
+
this.sessionManager.currentLabel = label;
|
|
555
546
|
await this.sendSignalMessage(`Switched to session #${label}.`);
|
|
556
547
|
}
|
|
557
548
|
});
|
|
@@ -562,8 +553,8 @@ export class SignalBotDaemon {
|
|
|
562
553
|
if (budgetMatch) {
|
|
563
554
|
const explicitBudgetLabel = budgetMatch[1] ? parseInt(budgetMatch[1], 10) : null;
|
|
564
555
|
this.scheduleSessionOp(async () => {
|
|
565
|
-
const label = explicitBudgetLabel ?? this.currentLabel;
|
|
566
|
-
const managed = label !== null ? this.
|
|
556
|
+
const label = explicitBudgetLabel ?? this.sessionManager.currentLabel;
|
|
557
|
+
const managed = label !== null ? this.sessionManager.get(label) : undefined;
|
|
567
558
|
if (label === null) {
|
|
568
559
|
await this.sendSignalMessage('No active session.');
|
|
569
560
|
}
|
|
@@ -572,7 +563,29 @@ export class SignalBotDaemon {
|
|
|
572
563
|
}
|
|
573
564
|
else {
|
|
574
565
|
const status = managed.session.getBudgetStatus();
|
|
575
|
-
await this.sendSignalMessage(prefixWithLabel(formatBudgetMessage(status), label, this.
|
|
566
|
+
await this.sendSignalMessage(prefixWithLabel(formatBudgetMessage(status), label, this.sessionManager.size));
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
// /jobs
|
|
572
|
+
if (lower === '/jobs') {
|
|
573
|
+
this.scheduleSessionOp(async () => {
|
|
574
|
+
try {
|
|
575
|
+
const { sendControlRequest } = await import('../daemon/control-socket.js');
|
|
576
|
+
const response = await sendControlRequest({ command: 'list-jobs' });
|
|
577
|
+
if (response?.ok) {
|
|
578
|
+
const jobs = (response.data ?? []);
|
|
579
|
+
await this.sendSignalMessage(formatJobList(jobs));
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
const errMsg = response ? response.error : 'daemon may not be running';
|
|
583
|
+
await this.sendSignalMessage(`Failed to query jobs: ${errMsg}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
588
|
+
await this.sendSignalMessage(`Failed to query jobs: ${msg}`);
|
|
576
589
|
}
|
|
577
590
|
});
|
|
578
591
|
return true;
|
|
@@ -580,7 +593,8 @@ export class SignalBotDaemon {
|
|
|
580
593
|
// /help
|
|
581
594
|
if (lower === '/help') {
|
|
582
595
|
this.scheduleSessionOp(async () => {
|
|
583
|
-
const
|
|
596
|
+
const currentLabel = this.sessionManager.currentLabel;
|
|
597
|
+
const sessionLine = currentLabel !== null ? `Current session: #${currentLabel}` : 'No active session.';
|
|
584
598
|
await this.sendSignalMessage(sessionLine +
|
|
585
599
|
'\n\n' +
|
|
586
600
|
'Commands:\n' +
|
|
@@ -590,6 +604,7 @@ export class SignalBotDaemon {
|
|
|
590
604
|
' #N <message> - send to session #N without switching\n' +
|
|
591
605
|
' /quit [N] - end session (current or #N)\n' +
|
|
592
606
|
' /budget [N] - show resource usage\n' +
|
|
607
|
+
' /jobs - list scheduled cron jobs\n' +
|
|
593
608
|
' /help - show this message\n' +
|
|
594
609
|
' approve [#N] - approve pending escalation\n' +
|
|
595
610
|
' deny [#N] - deny pending escalation');
|