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