sensorium-mcp 2.17.27 → 3.0.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/Install-Sensorium.ps1 +327 -0
- package/README.md +14 -0
- package/dist/config.d.ts +16 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +39 -2
- package/dist/config.js.map +1 -1
- package/dist/daily-session.d.ts +2 -1
- package/dist/daily-session.d.ts.map +1 -1
- package/dist/daily-session.js +23 -26
- package/dist/daily-session.js.map +1 -1
- package/dist/dashboard/routes/settings.d.ts +4 -0
- package/dist/dashboard/routes/settings.d.ts.map +1 -1
- package/dist/dashboard/routes/settings.js +57 -1
- package/dist/dashboard/routes/settings.js.map +1 -1
- package/dist/dashboard/routes/threads.d.ts +1 -0
- package/dist/dashboard/routes/threads.d.ts.map +1 -1
- package/dist/dashboard/routes/threads.js +23 -27
- package/dist/dashboard/routes/threads.js.map +1 -1
- package/dist/dashboard/routes.d.ts.map +1 -1
- package/dist/dashboard/routes.js +7 -2
- package/dist/dashboard/routes.js.map +1 -1
- package/dist/dashboard/spa.html +11 -11
- package/dist/data/interfaces.d.ts +36 -0
- package/dist/data/interfaces.d.ts.map +1 -0
- package/dist/data/interfaces.js +2 -0
- package/dist/data/interfaces.js.map +1 -0
- package/dist/data/memory/bootstrap.d.ts +36 -16
- package/dist/data/memory/bootstrap.d.ts.map +1 -1
- package/dist/data/memory/bootstrap.js +71 -217
- package/dist/data/memory/bootstrap.js.map +1 -1
- package/dist/data/memory/consolidation.d.ts +35 -34
- package/dist/data/memory/consolidation.d.ts.map +1 -1
- package/dist/data/memory/consolidation.js +43 -555
- package/dist/data/memory/consolidation.js.map +1 -1
- package/dist/data/memory/index.d.ts +0 -1
- package/dist/data/memory/index.d.ts.map +1 -1
- package/dist/data/memory/index.js +0 -1
- package/dist/data/memory/index.js.map +1 -1
- package/dist/data/memory/migration-runner.d.ts +5 -0
- package/dist/data/memory/migration-runner.d.ts.map +1 -0
- package/dist/data/memory/migration-runner.js +403 -0
- package/dist/data/memory/migration-runner.js.map +1 -0
- package/dist/data/memory/reflection.js +1 -1
- package/dist/data/memory/schema-ddl.d.ts +4 -0
- package/dist/data/memory/schema-ddl.d.ts.map +1 -0
- package/dist/data/memory/schema-ddl.js +194 -0
- package/dist/data/memory/schema-ddl.js.map +1 -0
- package/dist/data/memory/schema-guard.d.ts +3 -0
- package/dist/data/memory/schema-guard.d.ts.map +1 -0
- package/dist/data/memory/schema-guard.js +184 -0
- package/dist/data/memory/schema-guard.js.map +1 -0
- package/dist/data/memory/schema.d.ts +2 -5
- package/dist/data/memory/schema.d.ts.map +1 -1
- package/dist/data/memory/schema.js +6 -834
- package/dist/data/memory/schema.js.map +1 -1
- package/dist/data/memory/semantic.d.ts +0 -1
- package/dist/data/memory/semantic.d.ts.map +1 -1
- package/dist/data/memory/semantic.js +2 -8
- package/dist/data/memory/semantic.js.map +1 -1
- package/dist/data/memory/synthesis.js +2 -2
- package/dist/data/memory/synthesis.js.map +1 -1
- package/dist/data/memory/thread-registry.d.ts +18 -4
- package/dist/data/memory/thread-registry.d.ts.map +1 -1
- package/dist/data/memory/thread-registry.js +25 -0
- package/dist/data/memory/thread-registry.js.map +1 -1
- package/dist/data/sent-message.repository.d.ts +12 -0
- package/dist/data/sent-message.repository.d.ts.map +1 -0
- package/dist/data/sent-message.repository.js +31 -0
- package/dist/data/sent-message.repository.js.map +1 -0
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +23 -2
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +27 -48
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +7 -2
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +89 -12
- package/dist/logger.js.map +1 -1
- package/dist/scheduler.d.ts +8 -0
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +15 -0
- package/dist/scheduler.js.map +1 -1
- package/dist/server/factory.d.ts +2 -1
- package/dist/server/factory.d.ts.map +1 -1
- package/dist/server/factory.js +11 -4
- package/dist/server/factory.js.map +1 -1
- package/dist/services/agent-spawn.service.d.ts +39 -0
- package/dist/services/agent-spawn.service.d.ts.map +1 -0
- package/dist/services/agent-spawn.service.js +348 -0
- package/dist/services/agent-spawn.service.js.map +1 -0
- package/dist/services/background-runner.d.ts +26 -0
- package/dist/services/background-runner.d.ts.map +1 -0
- package/dist/services/background-runner.js +71 -0
- package/dist/services/background-runner.js.map +1 -0
- package/dist/services/consolidation.service.d.ts +16 -0
- package/dist/services/consolidation.service.d.ts.map +1 -0
- package/dist/services/consolidation.service.js +508 -0
- package/dist/services/consolidation.service.js.map +1 -0
- package/dist/services/dispatcher/broker.d.ts +2 -0
- package/dist/services/dispatcher/broker.d.ts.map +1 -1
- package/dist/services/dispatcher/broker.js +5 -10
- package/dist/services/dispatcher/broker.js.map +1 -1
- package/dist/services/dispatcher/index.d.ts +1 -1
- package/dist/services/dispatcher/index.d.ts.map +1 -1
- package/dist/services/dispatcher/index.js +1 -1
- package/dist/services/dispatcher/index.js.map +1 -1
- package/dist/services/dispatcher/lock.d.ts.map +1 -1
- package/dist/services/dispatcher/lock.js +7 -11
- package/dist/services/dispatcher/lock.js.map +1 -1
- package/dist/services/maintenance-signal.d.ts +18 -0
- package/dist/services/maintenance-signal.d.ts.map +1 -0
- package/dist/services/maintenance-signal.js +48 -0
- package/dist/services/maintenance-signal.js.map +1 -0
- package/dist/services/memory-briefing.service.d.ts +4 -0
- package/dist/services/memory-briefing.service.d.ts.map +1 -0
- package/dist/services/memory-briefing.service.js +143 -0
- package/dist/services/memory-briefing.service.js.map +1 -0
- package/dist/services/process.service.d.ts +31 -0
- package/dist/services/process.service.d.ts.map +1 -0
- package/dist/services/process.service.js +100 -0
- package/dist/services/process.service.js.map +1 -0
- package/dist/services/thread-health.service.d.ts +18 -0
- package/dist/services/thread-health.service.d.ts.map +1 -0
- package/dist/services/thread-health.service.js +118 -0
- package/dist/services/thread-health.service.js.map +1 -0
- package/dist/services/thread-lifecycle.service.d.ts +52 -0
- package/dist/services/thread-lifecycle.service.d.ts.map +1 -0
- package/dist/services/thread-lifecycle.service.js +174 -0
- package/dist/services/thread-lifecycle.service.js.map +1 -0
- package/dist/services/topic.service.d.ts +25 -0
- package/dist/services/topic.service.d.ts.map +1 -0
- package/dist/services/topic.service.js +65 -0
- package/dist/services/topic.service.js.map +1 -0
- package/dist/services/worker-cleanup.service.d.ts +8 -0
- package/dist/services/worker-cleanup.service.d.ts.map +1 -0
- package/dist/services/worker-cleanup.service.js +82 -0
- package/dist/services/worker-cleanup.service.js.map +1 -0
- package/dist/sessions.d.ts +14 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +55 -0
- package/dist/sessions.js.map +1 -1
- package/dist/telegram.d.ts +13 -6
- package/dist/telegram.d.ts.map +1 -1
- package/dist/telegram.js +43 -14
- package/dist/telegram.js.map +1 -1
- package/dist/tools/defs/memory-defs.d.ts.map +1 -1
- package/dist/tools/defs/memory-defs.js +0 -19
- package/dist/tools/defs/memory-defs.js.map +1 -1
- package/dist/tools/delegate-tool.d.ts +4 -0
- package/dist/tools/delegate-tool.d.ts.map +1 -1
- package/dist/tools/delegate-tool.js +48 -109
- package/dist/tools/delegate-tool.js.map +1 -1
- package/dist/tools/memory-tools.d.ts.map +1 -1
- package/dist/tools/memory-tools.js +1 -16
- package/dist/tools/memory-tools.js.map +1 -1
- package/dist/tools/shared-agent-utils.d.ts +9 -1
- package/dist/tools/shared-agent-utils.d.ts.map +1 -1
- package/dist/tools/shared-agent-utils.js +24 -42
- package/dist/tools/shared-agent-utils.js.map +1 -1
- package/dist/tools/start-session-tool.d.ts +2 -0
- package/dist/tools/start-session-tool.d.ts.map +1 -1
- package/dist/tools/start-session-tool.js +66 -106
- package/dist/tools/start-session-tool.js.map +1 -1
- package/dist/tools/thread-lifecycle.d.ts +5 -127
- package/dist/tools/thread-lifecycle.d.ts.map +1 -1
- package/dist/tools/thread-lifecycle.js +5 -1163
- package/dist/tools/thread-lifecycle.js.map +1 -1
- package/dist/tools/utility-tools.js +5 -2
- package/dist/tools/utility-tools.js.map +1 -1
- package/dist/tools/wait/drive-handler.d.ts +0 -1
- package/dist/tools/wait/drive-handler.d.ts.map +1 -1
- package/dist/tools/wait/drive-handler.js +5 -22
- package/dist/tools/wait/drive-handler.js.map +1 -1
- package/dist/tools/wait/message-delivery.js +1 -1
- package/dist/tools/wait/message-delivery.js.map +1 -1
- package/dist/tools/wait/message-processing.d.ts.map +1 -1
- package/dist/tools/wait/message-processing.js +9 -8
- package/dist/tools/wait/message-processing.js.map +1 -1
- package/dist/tools/wait/poll-loop.d.ts +2 -0
- package/dist/tools/wait/poll-loop.d.ts.map +1 -1
- package/dist/tools/wait/poll-loop.js +27 -29
- package/dist/tools/wait/poll-loop.js.map +1 -1
- package/dist/tools/wait/task-handler.d.ts +0 -3
- package/dist/tools/wait/task-handler.d.ts.map +1 -1
- package/dist/tools/wait/task-handler.js +3 -2
- package/dist/tools/wait/task-handler.js.map +1 -1
- package/dist/types.d.ts +0 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -8
- package/supervisor/config.go +182 -69
- package/supervisor/config_test.go +78 -0
- package/supervisor/go.mod +12 -0
- package/supervisor/go.sum +20 -0
- package/supervisor/health.go +60 -11
- package/supervisor/health_test.go +29 -0
- package/supervisor/keeper.go +15 -10
- package/supervisor/log.go +109 -28
- package/supervisor/log_test.go +86 -6
- package/supervisor/main.go +150 -19
- package/supervisor/main_test.go +130 -0
- package/supervisor/process.go +47 -4
- package/supervisor/process_test.go +14 -0
- package/supervisor/secrets.go +95 -0
- package/supervisor/secrets_securevault_test.go +98 -0
- package/supervisor/secrets_test.go +119 -0
- package/supervisor/self_update.go +282 -0
- package/supervisor/self_update_test.go +177 -0
- package/supervisor/service_restart_stub.go +9 -0
- package/supervisor/service_restart_windows.go +63 -0
- package/supervisor/service_stub.go +15 -0
- package/supervisor/service_windows.go +216 -0
- package/supervisor/update_state.go +264 -0
- package/supervisor/update_state_test.go +306 -0
- package/supervisor/updater.go +311 -10
- package/supervisor/updater_test.go +64 -0
- package/dist/data/memory/quality-scoring.d.ts +0 -32
- package/dist/data/memory/quality-scoring.d.ts.map +0 -1
- package/dist/data/memory/quality-scoring.js +0 -182
- package/dist/data/memory/quality-scoring.js.map +0 -1
- package/scripts/install-supervisor.ps1 +0 -67
- package/scripts/install-supervisor.sh +0 -43
- package/scripts/start-supervisor.ps1 +0 -46
- package/scripts/start-supervisor.sh +0 -20
- package/templates/coding-task.default.md +0 -12
|
@@ -1,1164 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* from
|
|
6
|
-
*/
|
|
7
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
-
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { homedir, tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { getClaudeMcpConfigPath } from "../config.js";
|
|
12
|
-
import { log } from "../logger.js";
|
|
13
|
-
import { getAllRegisteredTopics, getDashboardSessions, WAIT_LIVENESS_MS } from "../sessions.js";
|
|
14
|
-
import { synthesizeGhostMemory } from "../memory.js";
|
|
15
|
-
import { archiveThread, getAllThreads, getExplicitTelegramTopicId, getThread, updateThread } from "../data/memory/thread-registry.js";
|
|
16
|
-
import { initMemoryDb } from "../data/memory/schema.js";
|
|
17
|
-
import { archiveNotesForThread } from "../data/memory/semantic.js";
|
|
18
|
-
import { errorMessage } from "../utils.js";
|
|
19
|
-
/** Env vars that must NOT leak to spawned agent processes. */
|
|
20
|
-
const ENV_DENYLIST = new Set([
|
|
21
|
-
"TELEGRAM_TOKEN", "TELEGRAM_CHAT_ID", "MCP_HTTP_SECRET",
|
|
22
|
-
"DASHBOARD_TOKEN", "MCP_START_COMMAND", "WATCHER_START_COMMAND",
|
|
23
|
-
]);
|
|
24
|
-
/** Build a sanitized copy of process.env with secrets removed. */
|
|
25
|
-
function sanitizeSpawnEnv(extra) {
|
|
26
|
-
const env = {};
|
|
27
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
28
|
-
if (!ENV_DENYLIST.has(k))
|
|
29
|
-
env[k] = v;
|
|
30
|
-
}
|
|
31
|
-
if (extra)
|
|
32
|
-
Object.assign(env, extra);
|
|
33
|
-
return env;
|
|
34
|
-
}
|
|
35
|
-
import { COPILOT_HOME_DIR, DEFAULT_COPILOT_MODEL, writeCopilotHomeFiles, ensureCopilotWorkspace, } from "./shared-agent-utils.js";
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// In-memory registry of spawned processes
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
const spawnedThreads = [];
|
|
40
|
-
/** True while spawnKeepAliveThreads is killing stale PIDs — blocks concurrent spawns. */
|
|
41
|
-
let startupCleanupInProgress = false;
|
|
42
|
-
const MAX_CONCURRENT_THREADS = 20;
|
|
43
|
-
/**
|
|
44
|
-
* Check if a process with the given PID is still running.
|
|
45
|
-
* Uses process.kill(pid, 0) which is non-blocking (microseconds).
|
|
46
|
-
* On Windows, PID reuse is a theoretical risk but far less harmful than
|
|
47
|
-
* the 1-5 second event-loop blocking that tasklist causes. The batch
|
|
48
|
-
* getAlivePids() function still uses tasklist for startup restore where
|
|
49
|
-
* accuracy matters more than latency.
|
|
50
|
-
*/
|
|
51
|
-
export function isProcessAlive(pid) {
|
|
52
|
-
try {
|
|
53
|
-
process.kill(pid, 0);
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
56
|
-
catch (err) {
|
|
57
|
-
// EPERM = process exists but we can't signal it (e.g. different security context on Windows)
|
|
58
|
-
if (err.code === "EPERM")
|
|
59
|
-
return true;
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Find a spawned thread entry by threadId whose process is still alive.
|
|
65
|
-
* Searches from the end of the array to find the most recently spawned entry.
|
|
66
|
-
*/
|
|
67
|
-
export function findAliveThread(threadId) {
|
|
68
|
-
for (let i = spawnedThreads.length - 1; i >= 0; i--) {
|
|
69
|
-
const t = spawnedThreads[i];
|
|
70
|
-
if (t.threadId === threadId) {
|
|
71
|
-
if (isProcessAlive(t.pid))
|
|
72
|
-
return t;
|
|
73
|
-
log.warn(`[findAliveThread] Thread ${threadId} PID ${t.pid} in spawnedThreads but NOT alive — removing stale entry`);
|
|
74
|
-
spawnedThreads.splice(i, 1);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
// Check PID files for agents that survived a server restart.
|
|
78
|
-
// Even with non-detached spawning, keeper-spawned agents may outlive
|
|
79
|
-
// the server process and need to be re-discovered.
|
|
80
|
-
const pidEntry = readPidFiles().find((entry) => entry.threadId === threadId && isProcessAlive(entry.pid));
|
|
81
|
-
if (!pidEntry) {
|
|
82
|
-
log.debug(`[findAliveThread] Thread ${threadId}: not in spawnedThreads (${spawnedThreads.length} entries), not in PID files`);
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
// On Windows, EPERM from process.kill can be returned for dead processes whose
|
|
86
|
-
// handles haven't been cleaned up. Use tasklist as a secondary verification for
|
|
87
|
-
// PID-file-restored entries (not freshly spawned ones) to avoid zombie adoption.
|
|
88
|
-
if (process.platform === "win32") {
|
|
89
|
-
try {
|
|
90
|
-
const { execSync } = require("node:child_process");
|
|
91
|
-
const out = execSync(`tasklist /FI "PID eq ${pidEntry.pid}" /NH`, { encoding: "utf-8", timeout: 5000 });
|
|
92
|
-
if (!out.includes(String(pidEntry.pid))) {
|
|
93
|
-
log.warn(`[findAliveThread] Thread ${threadId} PID ${pidEntry.pid} EPERM-alive but not in tasklist — zombie`);
|
|
94
|
-
try {
|
|
95
|
-
unlinkSync(pidEntry.filePath);
|
|
96
|
-
}
|
|
97
|
-
catch { /* ok */ }
|
|
98
|
-
return undefined;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
catch { /* tasklist failed, trust isProcessAlive */ }
|
|
102
|
-
}
|
|
103
|
-
const restored = {
|
|
104
|
-
pid: pidEntry.pid,
|
|
105
|
-
threadId,
|
|
106
|
-
name: pidEntry.name ?? `Thread ${threadId}`,
|
|
107
|
-
startedAt: Date.now(),
|
|
108
|
-
createdAt: Date.now(),
|
|
109
|
-
logFile: "",
|
|
110
|
-
};
|
|
111
|
-
spawnedThreads.push(restored);
|
|
112
|
-
log.info(`[findAliveThread] Restored thread ${threadId} PID=${pidEntry.pid} from PID file`);
|
|
113
|
-
return restored;
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Check if any tracked process is running for this threadId.
|
|
117
|
-
*/
|
|
118
|
-
export function isThreadRunning(threadId) {
|
|
119
|
-
return findAliveThread(threadId) !== undefined;
|
|
120
|
-
}
|
|
121
|
-
// ---------------------------------------------------------------------------
|
|
122
|
-
// Directory constants & helpers
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
|
-
const BASE_DIR = join(homedir(), ".remote-copilot-mcp");
|
|
125
|
-
export const PENDING_TASKS_DIR = join(BASE_DIR, "pending-tasks");
|
|
126
|
-
const LOGS_DIR = join(BASE_DIR, "logs");
|
|
127
|
-
const PIDS_DIR = join(BASE_DIR, "pids");
|
|
128
|
-
const WATCHER_PORT = Number.parseInt(process.env.WATCHER_PORT || "3848", 10);
|
|
129
|
-
export function ensureDirs() {
|
|
130
|
-
mkdirSync(PENDING_TASKS_DIR, { recursive: true });
|
|
131
|
-
mkdirSync(LOGS_DIR, { recursive: true });
|
|
132
|
-
mkdirSync(PIDS_DIR, { recursive: true });
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Resolve the MCP config path for the spawned Claude process.
|
|
136
|
-
* Priority:
|
|
137
|
-
* 1. CLAUDE_MCP_CONFIG env var
|
|
138
|
-
* 2. Dashboard setting (claudeMcpConfigPath in settings.json)
|
|
139
|
-
* 3. ~/.claude/settings.json
|
|
140
|
-
* 4. ~/.claude/mcp_config.json
|
|
141
|
-
* 5. ~/.claude/.mcp.json
|
|
142
|
-
*/
|
|
143
|
-
/**
|
|
144
|
-
* Generate a per-thread MCP config that includes the sensorium-watcher server.
|
|
145
|
-
* This allows ghost threads to call `await_server_ready` during server updates
|
|
146
|
-
* instead of falling back to a blind 600s sleep.
|
|
147
|
-
* Returns the path to the generated config, or the original path on failure.
|
|
148
|
-
*/
|
|
149
|
-
function generateThreadMcpConfig(baseConfigPath, threadId) {
|
|
150
|
-
const outPath = join(PIDS_DIR, `${threadId}-mcp-config.json`);
|
|
151
|
-
try {
|
|
152
|
-
const raw = readFileSync(baseConfigPath, "utf-8");
|
|
153
|
-
const config = JSON.parse(raw);
|
|
154
|
-
const servers = (config.mcpServers ?? {});
|
|
155
|
-
if (!servers["sensorium-watcher"]) {
|
|
156
|
-
servers["sensorium-watcher"] = {
|
|
157
|
-
type: "http",
|
|
158
|
-
url: `http://127.0.0.1:${WATCHER_PORT}/mcp`,
|
|
159
|
-
};
|
|
160
|
-
config.mcpServers = servers;
|
|
161
|
-
mkdirSync(PIDS_DIR, { recursive: true });
|
|
162
|
-
writeFileSync(outPath, JSON.stringify(config, null, 2), "utf-8");
|
|
163
|
-
return outPath;
|
|
164
|
-
}
|
|
165
|
-
return baseConfigPath; // watcher already in config
|
|
166
|
-
}
|
|
167
|
-
catch (err) {
|
|
168
|
-
log.warn(`[start_thread] Failed to generate merged MCP config for thread ${threadId}: ${errorMessage(err)}`);
|
|
169
|
-
return baseConfigPath;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
export function resolveMcpConfigPath() {
|
|
173
|
-
const envPath = process.env.CLAUDE_MCP_CONFIG;
|
|
174
|
-
if (envPath && existsSync(envPath))
|
|
175
|
-
return envPath;
|
|
176
|
-
const dashboardPath = getClaudeMcpConfigPath();
|
|
177
|
-
if (dashboardPath && existsSync(dashboardPath))
|
|
178
|
-
return dashboardPath;
|
|
179
|
-
const candidates = [
|
|
180
|
-
join(homedir(), ".claude", "settings.json"),
|
|
181
|
-
join(homedir(), ".claude", "mcp_config.json"),
|
|
182
|
-
join(homedir(), ".claude", ".mcp.json"),
|
|
183
|
-
];
|
|
184
|
-
for (const p of candidates) {
|
|
185
|
-
if (existsSync(p))
|
|
186
|
-
return p;
|
|
187
|
-
}
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Resolve the absolute path to the `claude` CLI executable.
|
|
192
|
-
* Returns null if not found. Uses where/which instead of execSync (L3).
|
|
193
|
-
*/
|
|
194
|
-
export function resolveClaudePath() {
|
|
195
|
-
try {
|
|
196
|
-
const cmd = process.platform === "win32" ? "where" : "which";
|
|
197
|
-
const result = spawnSync(cmd, ["claude"], { timeout: 5000, encoding: "utf-8" });
|
|
198
|
-
if (result.status === 0 && result.stdout) {
|
|
199
|
-
return result.stdout.trim().split(/\r?\n/)[0];
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
catch { /* not found */ }
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
/**
|
|
206
|
-
* Resolve the absolute path to the `copilot` CLI executable.
|
|
207
|
-
* Checks COPILOT_CLI_CMD env var first, then PATH. Returns null if not found.
|
|
208
|
-
*/
|
|
209
|
-
export function resolveCopilotPath() {
|
|
210
|
-
const envCmd = process.env.COPILOT_CLI_CMD;
|
|
211
|
-
if (envCmd)
|
|
212
|
-
return envCmd;
|
|
213
|
-
try {
|
|
214
|
-
const cmd = process.platform === "win32" ? "where" : "which";
|
|
215
|
-
const result = spawnSync(cmd, ["copilot"], { timeout: 5000, encoding: "utf-8" });
|
|
216
|
-
if (result.status === 0 && result.stdout) {
|
|
217
|
-
return result.stdout.trim().split(/\r?\n/)[0];
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
catch { /* not found */ }
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
// Copilot home helpers
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
// ---------------------------------------------------------------------------
|
|
227
|
-
// Codex home helpers
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
const CODEX_HOME_DIR = join(BASE_DIR, "codex-home");
|
|
230
|
-
// No hardcoded default — let the codex CLI pick its own default.
|
|
231
|
-
// Override via CODEX_MODEL env var if needed.
|
|
232
|
-
const DEFAULT_CODEX_MODEL = "";
|
|
233
|
-
/**
|
|
234
|
-
* Resolve the absolute path to the `codex` CLI executable.
|
|
235
|
-
* Checks CODEX_CLI_CMD env var first, then PATH. Returns null if not found.
|
|
236
|
-
*/
|
|
237
|
-
export function resolveCodexPath() {
|
|
238
|
-
const envCmd = process.env.CODEX_CLI_CMD;
|
|
239
|
-
if (envCmd)
|
|
240
|
-
return envCmd;
|
|
241
|
-
try {
|
|
242
|
-
const cmd = process.platform === "win32" ? "where" : "which";
|
|
243
|
-
const result = spawnSync(cmd, ["codex"], { timeout: 5000, encoding: "utf-8" });
|
|
244
|
-
if (result.status === 0 && result.stdout) {
|
|
245
|
-
const candidates = result.stdout.trim().split(/\r?\n/);
|
|
246
|
-
// On Windows, prefer codex.cmd over the bare bash shim so that needsShell
|
|
247
|
-
// is correctly set and stdio fd inheritance works (same pattern as claude.cmd).
|
|
248
|
-
if (process.platform === "win32") {
|
|
249
|
-
const cmdVariant = candidates.find(p => /\.cmd$/i.test(p));
|
|
250
|
-
if (cmdVariant)
|
|
251
|
-
return cmdVariant;
|
|
252
|
-
}
|
|
253
|
-
return candidates[0];
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
catch { /* not found */ }
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
/**
|
|
260
|
-
* On Windows, Volta wraps codex in cmd→volta→cmd→node, breaking file descriptor
|
|
261
|
-
* inheritance. Resolve node.exe + codex.js directly so we can spawn without any
|
|
262
|
-
* Volta wrapper process in the chain.
|
|
263
|
-
* Returns null if the paths cannot be found (fallback to codex.cmd).
|
|
264
|
-
*/
|
|
265
|
-
function resolveCodexNodeExe() {
|
|
266
|
-
if (process.platform !== "win32")
|
|
267
|
-
return null;
|
|
268
|
-
try {
|
|
269
|
-
const localAppData = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local");
|
|
270
|
-
const voltaImage = join(localAppData, "Volta", "tools", "image");
|
|
271
|
-
// Find the codex.js script (stable path relative to Volta's image dir)
|
|
272
|
-
const codexJs = join(voltaImage, "packages", "@openai", "codex", "node_modules", "@openai", "codex", "bin", "codex.js");
|
|
273
|
-
if (!existsSync(codexJs))
|
|
274
|
-
return null;
|
|
275
|
-
// Ask Volta for the node.exe path it would use (synchronous, fast)
|
|
276
|
-
const voltaCmd = join(localAppData, "Volta", "bin", "volta.exe");
|
|
277
|
-
const nodePathResult = spawnSync(voltaCmd, ["run", "node", "-e", "process.stdout.write(process.execPath)"], { encoding: "utf-8", timeout: 5000 });
|
|
278
|
-
if (nodePathResult.status === 0 && nodePathResult.stdout?.trim()) {
|
|
279
|
-
return { nodeExe: nodePathResult.stdout.trim(), codexJs };
|
|
280
|
-
}
|
|
281
|
-
// Fallback: pick the highest-version node from Volta's image dir
|
|
282
|
-
const nodeDir = join(voltaImage, "node");
|
|
283
|
-
if (existsSync(nodeDir)) {
|
|
284
|
-
const versions = readdirSync(nodeDir).filter(v => /^\d+\.\d+\.\d+$/.test(v)).sort((a, b) => {
|
|
285
|
-
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
286
|
-
for (let i = 0; i < 3; i++) {
|
|
287
|
-
if (pa[i] !== pb[i])
|
|
288
|
-
return pb[i] - pa[i];
|
|
289
|
-
}
|
|
290
|
-
return 0;
|
|
291
|
-
});
|
|
292
|
-
for (const ver of versions) {
|
|
293
|
-
const nodeExe = join(nodeDir, ver, "node.exe");
|
|
294
|
-
if (existsSync(nodeExe))
|
|
295
|
-
return { nodeExe, codexJs };
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
catch { /* ignore */ }
|
|
300
|
-
return null;
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Resolve the native codex.exe binary installed by Volta.
|
|
304
|
-
* Bypasses the codex.js Node.js wrapper and the entire Volta shim chain.
|
|
305
|
-
* Returns null if the binary cannot be found.
|
|
306
|
-
*/
|
|
307
|
-
function resolveCodexExe() {
|
|
308
|
-
if (process.platform !== "win32")
|
|
309
|
-
return null;
|
|
310
|
-
// Allow operator override via env var
|
|
311
|
-
if (process.env.CODEX_EXE)
|
|
312
|
-
return process.env.CODEX_EXE;
|
|
313
|
-
try {
|
|
314
|
-
const localAppData = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local");
|
|
315
|
-
const nativeExe = join(localAppData, "Volta", "tools", "image", "packages", "@openai", "codex", "node_modules", "@openai", "codex", "node_modules", "@openai", "codex-win32-x64", "vendor", "x86_64-pc-windows-msvc", "codex", "codex.exe");
|
|
316
|
-
if (existsSync(nativeExe))
|
|
317
|
-
return nativeExe;
|
|
318
|
-
}
|
|
319
|
-
catch { /* ignore */ }
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
// ---------------------------------------------------------------------------
|
|
323
|
-
// Shared exit handler for all spawned processes
|
|
324
|
-
// ---------------------------------------------------------------------------
|
|
325
|
-
async function handleProcessExit(code, threadId, pid, pidFilePath, entry, processLabel) {
|
|
326
|
-
const idx = spawnedThreads.indexOf(entry);
|
|
327
|
-
if (idx !== -1)
|
|
328
|
-
spawnedThreads.splice(idx, 1);
|
|
329
|
-
try {
|
|
330
|
-
unlinkSync(pidFilePath);
|
|
331
|
-
}
|
|
332
|
-
catch { /* already removed */ }
|
|
333
|
-
// Update thread registry DB status
|
|
334
|
-
try {
|
|
335
|
-
const db = initMemoryDb();
|
|
336
|
-
// Root, branch, and keepAlive threads stay 'active' so they remain visible on the dashboard
|
|
337
|
-
// and the keeper can restart them. Only workers auto-exit.
|
|
338
|
-
const existing = getThread(db, threadId);
|
|
339
|
-
const newStatus = (existing?.keepAlive || existing?.type === 'root' || existing?.type === 'branch') ? 'active' : 'exited';
|
|
340
|
-
updateThread(db, threadId, { status: newStatus, lastActiveAt: new Date().toISOString() });
|
|
341
|
-
// Synthesize ghost thread outcomes back to parent
|
|
342
|
-
if (entry.memorySourceThreadId !== undefined) {
|
|
343
|
-
try {
|
|
344
|
-
const result = await synthesizeGhostMemory(db, threadId, entry.memorySourceThreadId, entry.name);
|
|
345
|
-
if (result.synthesizedNotes > 0 || result.synthesizedEpisode) {
|
|
346
|
-
log.info(`[synthesis] Ghost ${threadId} → parent ${entry.memorySourceThreadId}: ${result.synthesizedNotes} notes, episode: ${result.synthesizedEpisode}`);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
catch (err) {
|
|
350
|
-
log.warn(`[synthesis] Failed for ghost ${threadId}: ${errorMessage(err)}`);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
// Delete Telegram topic for completed worker threads (immediate cleanup)
|
|
354
|
-
if (entry.threadType === 'worker') {
|
|
355
|
-
try {
|
|
356
|
-
const token = process.env.TELEGRAM_TOKEN || "";
|
|
357
|
-
const chatId = process.env.TELEGRAM_CHAT_ID || "";
|
|
358
|
-
if (token && chatId) {
|
|
359
|
-
const topicId = getExplicitTelegramTopicId(db, threadId);
|
|
360
|
-
if (topicId != null) {
|
|
361
|
-
await fetch(`https://api.telegram.org/bot${token}/deleteForumTopic`, {
|
|
362
|
-
method: "POST",
|
|
363
|
-
headers: { "Content-Type": "application/json" },
|
|
364
|
-
body: JSON.stringify({ chat_id: chatId, message_thread_id: topicId }),
|
|
365
|
-
signal: AbortSignal.timeout(10_000),
|
|
366
|
-
});
|
|
367
|
-
log.info(`[cleanup] Deleted Telegram topic for worker ${threadId}`);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
catch { /* topic deletion is best-effort */ }
|
|
372
|
-
// Archive the worker in the registry
|
|
373
|
-
try {
|
|
374
|
-
archiveThread(db, threadId);
|
|
375
|
-
archiveNotesForThread(db, threadId);
|
|
376
|
-
}
|
|
377
|
-
catch { /* best-effort */ }
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
catch (err) {
|
|
381
|
-
log.warn(`[start_thread] Failed to update DB on exit for thread ${threadId}: ${errorMessage(err)}`);
|
|
382
|
-
}
|
|
383
|
-
log.info(`[start_thread] ${processLabel} process PID=${pid} for thread ${threadId} exited with code ${code}`);
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* Common post-spawn bookkeeping shared by all agent spawn functions:
|
|
387
|
-
* PID validation, PID file write, in-memory registry, exit handler, unref.
|
|
388
|
-
*/
|
|
389
|
-
function registerSpawnedProcess(opts) {
|
|
390
|
-
const { child, threadId, name, logFilePath, configPath, agentLabel } = opts;
|
|
391
|
-
const pid = child.pid;
|
|
392
|
-
if (pid === undefined) {
|
|
393
|
-
return { error: `${agentLabel} process spawned but PID is undefined — spawn may have failed.` };
|
|
394
|
-
}
|
|
395
|
-
const pidFilePath = join(PIDS_DIR, `${threadId}.pid`);
|
|
396
|
-
try {
|
|
397
|
-
writeFileSync(pidFilePath, JSON.stringify({ pid, name, configPath, startedAt: Date.now() }), "utf-8");
|
|
398
|
-
}
|
|
399
|
-
catch (err) {
|
|
400
|
-
log.debug(`[start_thread] Failed to write PID file: ${errorMessage(err)}`);
|
|
401
|
-
}
|
|
402
|
-
const entry = {
|
|
403
|
-
pid,
|
|
404
|
-
threadId,
|
|
405
|
-
name,
|
|
406
|
-
startedAt: Date.now(),
|
|
407
|
-
createdAt: Date.now(),
|
|
408
|
-
logFile: logFilePath,
|
|
409
|
-
...(opts.memorySourceThreadId !== undefined ? { memorySourceThreadId: opts.memorySourceThreadId } : {}),
|
|
410
|
-
...(opts.memoryTargetThreadId !== undefined ? { memoryTargetThreadId: opts.memoryTargetThreadId } : {}),
|
|
411
|
-
...(opts.threadType ? { threadType: opts.threadType } : {}),
|
|
412
|
-
};
|
|
413
|
-
spawnedThreads.push(entry);
|
|
414
|
-
child.on("exit", (code) => {
|
|
415
|
-
handleProcessExit(code, threadId, pid, pidFilePath, entry, agentLabel)
|
|
416
|
-
.catch(err => log.warn(`[exit] cleanup failed: ${err}`));
|
|
417
|
-
});
|
|
418
|
-
child.unref();
|
|
419
|
-
log.info(`[start_thread] Spawned ${agentLabel} process PID=${pid} for thread ${threadId} ("${name}")`);
|
|
420
|
-
return { pid, logFile: logFilePath };
|
|
421
|
-
}
|
|
422
|
-
// ---------------------------------------------------------------------------
|
|
423
|
-
// Spawn agent process
|
|
424
|
-
// ---------------------------------------------------------------------------
|
|
425
|
-
export function spawnAgentProcess(claudePath, mcpConfigPath, name, threadId, workingDirectory, memorySourceThreadId, memoryTargetThreadId, threadType) {
|
|
426
|
-
if (startupCleanupInProgress) {
|
|
427
|
-
return { error: "Server startup cleanup in progress — try again in a few seconds" };
|
|
428
|
-
}
|
|
429
|
-
if (spawnedThreads.length >= MAX_CONCURRENT_THREADS) {
|
|
430
|
-
return { error: `Concurrent thread limit reached (${MAX_CONCURRENT_THREADS}). Wait for existing threads to finish.` };
|
|
431
|
-
}
|
|
432
|
-
if (workingDirectory && !existsSync(workingDirectory)) {
|
|
433
|
-
const fallback = tmpdir();
|
|
434
|
-
log.warn(`workingDirectory "${workingDirectory}" does not exist, falling back to "${fallback}"`);
|
|
435
|
-
workingDirectory = fallback;
|
|
436
|
-
}
|
|
437
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
438
|
-
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
439
|
-
const logFileName = `${safeName}_${threadId}_${dateStr}.json`;
|
|
440
|
-
const logFilePath = join(LOGS_DIR, logFileName);
|
|
441
|
-
const logFd = openSync(logFilePath, "a");
|
|
442
|
-
// Generate a per-thread MCP config that includes sensorium-watcher for
|
|
443
|
-
// graceful server-update reconnection (Issue A1 fix).
|
|
444
|
-
const effectiveConfigPath = generateThreadMcpConfig(mcpConfigPath, threadId);
|
|
445
|
-
// Use the original name (not safeName) in the prompt so the spawned agent
|
|
446
|
-
// calls start_session with the exact name stored in the session registry.
|
|
447
|
-
// safeName is only for filesystem-safe log filenames.
|
|
448
|
-
const prompt = `Start remote session with sensorium. Thread name = '${name}'`;
|
|
449
|
-
const cliArgs = [
|
|
450
|
-
"--verbose",
|
|
451
|
-
"--dangerously-skip-permissions",
|
|
452
|
-
"--mcp-config", effectiveConfigPath,
|
|
453
|
-
"-p", prompt,
|
|
454
|
-
"--output-format", "stream-json",
|
|
455
|
-
"--include-partial-messages",
|
|
456
|
-
];
|
|
457
|
-
// Use shell only when the resolved path is a Windows batch script (.cmd/.bat)
|
|
458
|
-
const needsShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(claudePath);
|
|
459
|
-
// On Windows, ensure CLAUDE_CODE_GIT_BASH_PATH is set for the child process.
|
|
460
|
-
const spawnEnv = sanitizeSpawnEnv({
|
|
461
|
-
...(memorySourceThreadId !== undefined ? { MEMORY_SOURCE_THREAD_ID: String(memorySourceThreadId) } : {}),
|
|
462
|
-
...(memoryTargetThreadId !== undefined ? { MEMORY_TARGET_THREAD_ID: String(memoryTargetThreadId) } : {}),
|
|
463
|
-
});
|
|
464
|
-
if (process.platform === "win32" && !spawnEnv.CLAUDE_CODE_GIT_BASH_PATH) {
|
|
465
|
-
const gitBashCandidates = [
|
|
466
|
-
join(homedir(), "AppData", "Local", "Programs", "Git", "bin", "bash.exe"),
|
|
467
|
-
"C:\\Program Files\\Git\\bin\\bash.exe",
|
|
468
|
-
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
|
469
|
-
];
|
|
470
|
-
for (const candidate of gitBashCandidates) {
|
|
471
|
-
if (existsSync(candidate)) {
|
|
472
|
-
spawnEnv.CLAUDE_CODE_GIT_BASH_PATH = candidate;
|
|
473
|
-
log.info(`[start_thread] Auto-detected git-bash at ${candidate}`);
|
|
474
|
-
break;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
let child;
|
|
479
|
-
try {
|
|
480
|
-
child = spawn(claudePath, cliArgs, {
|
|
481
|
-
stdio: ["ignore", logFd, logFd],
|
|
482
|
-
shell: needsShell,
|
|
483
|
-
detached: true,
|
|
484
|
-
windowsHide: true,
|
|
485
|
-
env: spawnEnv,
|
|
486
|
-
cwd: workingDirectory || undefined,
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
catch (err) {
|
|
490
|
-
closeSync(logFd);
|
|
491
|
-
return { error: `Failed to spawn Claude process: ${errorMessage(err)}` };
|
|
492
|
-
}
|
|
493
|
-
// Release the parent's copy of the log file descriptor
|
|
494
|
-
closeSync(logFd);
|
|
495
|
-
return registerSpawnedProcess({
|
|
496
|
-
child, threadId, name, logFilePath,
|
|
497
|
-
configPath: effectiveConfigPath,
|
|
498
|
-
agentLabel: "Claude",
|
|
499
|
-
memorySourceThreadId, memoryTargetThreadId, threadType,
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
/**
|
|
503
|
-
* Spawn a GitHub Copilot agent process for the given thread.
|
|
504
|
-
* Writes Copilot home files (MCP config + system prompt) before spawning.
|
|
505
|
-
* Requires MCP_HTTP_PORT env var to be set.
|
|
506
|
-
*/
|
|
507
|
-
export function spawnCopilotProcess(copilotPath, name, threadId, workingDirectory, memorySourceThreadId, agentType, threadType) {
|
|
508
|
-
const httpPort = parseInt(process.env.MCP_HTTP_PORT || "0", 10);
|
|
509
|
-
if (!httpPort) {
|
|
510
|
-
return { error: "MCP_HTTP_PORT env var is not set or invalid. Copilot threads require HTTP transport." };
|
|
511
|
-
}
|
|
512
|
-
const httpSecret = process.env.MCP_HTTP_SECRET || null;
|
|
513
|
-
// Validate workingDirectory — a non-existent cwd causes ENOENT on spawn
|
|
514
|
-
if (workingDirectory && !existsSync(workingDirectory)) {
|
|
515
|
-
const fallback = tmpdir();
|
|
516
|
-
log.warn(`workingDirectory "${workingDirectory}" does not exist, falling back to "${fallback}"`);
|
|
517
|
-
workingDirectory = fallback;
|
|
518
|
-
}
|
|
519
|
-
const copilotHomeDir = join(BASE_DIR, COPILOT_HOME_DIR);
|
|
520
|
-
writeCopilotHomeFiles(copilotHomeDir, httpPort, httpSecret);
|
|
521
|
-
// Use a dedicated workspace with .copilotignore to prevent file scanning
|
|
522
|
-
if (!workingDirectory) {
|
|
523
|
-
workingDirectory = ensureCopilotWorkspace(BASE_DIR);
|
|
524
|
-
}
|
|
525
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
526
|
-
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
527
|
-
const logFileName = `${safeName}_${threadId}_${dateStr}.json`;
|
|
528
|
-
const logFilePath = join(LOGS_DIR, logFileName);
|
|
529
|
-
const logFd = openSync(logFilePath, "a");
|
|
530
|
-
const prompt = `Start remote session with sensorium. Thread name = '${name}'`;
|
|
531
|
-
const copilotModel = agentType === "copilot_codex"
|
|
532
|
-
? "gpt-5.3-codex"
|
|
533
|
-
: (process.env.COPILOT_MODEL || DEFAULT_COPILOT_MODEL);
|
|
534
|
-
const cliArgs = [
|
|
535
|
-
"-p", prompt,
|
|
536
|
-
"--allow-all-tools",
|
|
537
|
-
"--model", copilotModel,
|
|
538
|
-
"--autopilot",
|
|
539
|
-
];
|
|
540
|
-
const spawnEnv = sanitizeSpawnEnv({
|
|
541
|
-
COPILOT_HOME: copilotHomeDir,
|
|
542
|
-
...(memorySourceThreadId !== undefined ? { MEMORY_SOURCE_THREAD_ID: String(memorySourceThreadId) } : {}),
|
|
543
|
-
});
|
|
544
|
-
// Use shell only when the resolved path is a Windows batch script (.cmd/.bat).
|
|
545
|
-
// copilot.exe is a direct executable and does not need shell wrapping — shell
|
|
546
|
-
// wrapping on Windows breaks stdio fd inheritance causing empty log files.
|
|
547
|
-
const needsShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(copilotPath);
|
|
548
|
-
let child;
|
|
549
|
-
try {
|
|
550
|
-
child = spawn(copilotPath, cliArgs, {
|
|
551
|
-
stdio: ["ignore", logFd, logFd],
|
|
552
|
-
shell: needsShell,
|
|
553
|
-
detached: true,
|
|
554
|
-
windowsHide: true,
|
|
555
|
-
env: spawnEnv,
|
|
556
|
-
cwd: workingDirectory || undefined,
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
catch (err) {
|
|
560
|
-
closeSync(logFd);
|
|
561
|
-
return { error: `Failed to spawn Copilot process: ${errorMessage(err)}` };
|
|
562
|
-
}
|
|
563
|
-
closeSync(logFd);
|
|
564
|
-
return registerSpawnedProcess({
|
|
565
|
-
child, threadId, name, logFilePath,
|
|
566
|
-
configPath: copilotHomeDir,
|
|
567
|
-
agentLabel: "Copilot",
|
|
568
|
-
memorySourceThreadId, threadType,
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
/**
|
|
572
|
-
* Spawn an OpenAI Codex CLI agent process for the given thread.
|
|
573
|
-
* Writes Codex home files (TOML config + AGENTS.md) before spawning.
|
|
574
|
-
* Uses `codex exec` for non-interactive execution.
|
|
575
|
-
* Requires MCP_HTTP_PORT env var to be set.
|
|
576
|
-
*/
|
|
577
|
-
export function spawnCodexProcess(codexPath, name, threadId, workingDirectory, memorySourceThreadId, threadType) {
|
|
578
|
-
const httpPort = parseInt(process.env.MCP_HTTP_PORT || "0", 10);
|
|
579
|
-
if (!httpPort) {
|
|
580
|
-
return { error: "MCP_HTTP_PORT env var is not set or invalid. Codex threads require HTTP transport." };
|
|
581
|
-
}
|
|
582
|
-
const httpSecret = process.env.MCP_HTTP_SECRET || null;
|
|
583
|
-
const httpMcpUrl = `http://127.0.0.1:${httpPort}/mcp`;
|
|
584
|
-
// Validate workingDirectory — a non-existent cwd causes ENOENT on spawn
|
|
585
|
-
// (error message misleadingly shows the binary path, not the bad cwd).
|
|
586
|
-
if (workingDirectory && !existsSync(workingDirectory)) {
|
|
587
|
-
const fallback = tmpdir();
|
|
588
|
-
log.warn(`workingDirectory "${workingDirectory}" does not exist, falling back to "${fallback}"`);
|
|
589
|
-
workingDirectory = fallback;
|
|
590
|
-
}
|
|
591
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
592
|
-
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
593
|
-
const logFileName = `${safeName}_${threadId}_${dateStr}.jsonl`;
|
|
594
|
-
const logFilePath = join(LOGS_DIR, logFileName);
|
|
595
|
-
const prompt = `You are an AUTONOMOUS AGENT running an INFINITE polling loop. You MUST NEVER STOP.\n\n` +
|
|
596
|
-
`Step 1: Call start_session(name='${name}', agentType='codex'). Note the threadId from the result.\n` +
|
|
597
|
-
`Step 2: Call remote_copilot_wait_for_instructions(threadId=<from step 1>).\n` +
|
|
598
|
-
`Step 3: Read the response:\n` +
|
|
599
|
-
` - If it says "MANDATORY ACTION" or "No new instructions" — call remote_copilot_wait_for_instructions AGAIN immediately.\n` +
|
|
600
|
-
` - If you receive a task — complete it using your tools, then call remote_copilot_wait_for_instructions again.\n` +
|
|
601
|
-
`Step 4: GOTO Step 2. This loop NEVER ends. You are NEVER done.\n\n` +
|
|
602
|
-
`CRITICAL: "No new instructions" is NOT a signal to stop. It means "keep waiting". Always call the tool again.`;
|
|
603
|
-
const codexModel = process.env.CODEX_MODEL || DEFAULT_CODEX_MODEL;
|
|
604
|
-
// Inject MCP server config via -c flags (avoids CODEX_HOME override which breaks auth).
|
|
605
|
-
// Codex uses ~/.codex for auth — we must not redirect CODEX_HOME away from it.
|
|
606
|
-
const mcpConfigArgs = [
|
|
607
|
-
"-c", `mcp_servers.sensorium-mcp.url="${httpMcpUrl}"`,
|
|
608
|
-
...(httpSecret ? ["-c", `mcp_servers.sensorium-mcp.bearer_token_env_var="SENSORIUM_MCP_SECRET"`] : []),
|
|
609
|
-
];
|
|
610
|
-
// Pass prompt via stdin ("-") to avoid shell quoting issues with multi-word prompts.
|
|
611
|
-
const cliArgs = [
|
|
612
|
-
"exec",
|
|
613
|
-
"--dangerously-bypass-approvals-and-sandbox",
|
|
614
|
-
"--skip-git-repo-check",
|
|
615
|
-
...(codexModel ? ["-m", codexModel] : []),
|
|
616
|
-
"--json",
|
|
617
|
-
...mcpConfigArgs,
|
|
618
|
-
"-",
|
|
619
|
-
];
|
|
620
|
-
if (workingDirectory) {
|
|
621
|
-
cliArgs.splice(1, 0, "-C", workingDirectory);
|
|
622
|
-
}
|
|
623
|
-
const spawnEnv = sanitizeSpawnEnv({
|
|
624
|
-
...(memorySourceThreadId !== undefined ? { MEMORY_SOURCE_THREAD_ID: String(memorySourceThreadId) } : {}),
|
|
625
|
-
// Forward MCP secret so codex's bearer_token_env_var can reference it
|
|
626
|
-
...(httpSecret ? { SENSORIUM_MCP_SECRET: httpSecret } : {}),
|
|
627
|
-
});
|
|
628
|
-
const logFd = openSync(logFilePath, "a");
|
|
629
|
-
let child;
|
|
630
|
-
try {
|
|
631
|
-
// Preferred: spawn the native codex.exe binary directly (no Volta wrapper chain).
|
|
632
|
-
// Fallback: node.exe + codex.js (also bypasses Volta for fd inheritance).
|
|
633
|
-
// Last resort: spawn codexPath directly (works on Mac/Linux).
|
|
634
|
-
const nativeExe = resolveCodexExe();
|
|
635
|
-
const nodeExeResult = !nativeExe && process.platform === "win32" && /\.(cmd|bat)$/i.test(codexPath)
|
|
636
|
-
? resolveCodexNodeExe()
|
|
637
|
-
: null;
|
|
638
|
-
if (nativeExe) {
|
|
639
|
-
child = spawn(nativeExe, cliArgs, {
|
|
640
|
-
stdio: ["pipe", logFd, logFd],
|
|
641
|
-
shell: false,
|
|
642
|
-
detached: true,
|
|
643
|
-
windowsHide: true,
|
|
644
|
-
env: spawnEnv,
|
|
645
|
-
cwd: workingDirectory || undefined,
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
else if (nodeExeResult) {
|
|
649
|
-
const { nodeExe, codexJs } = nodeExeResult;
|
|
650
|
-
const nodeArgs = [codexJs, ...cliArgs];
|
|
651
|
-
child = spawn(nodeExe, nodeArgs, {
|
|
652
|
-
stdio: ["pipe", logFd, logFd],
|
|
653
|
-
shell: false,
|
|
654
|
-
detached: true,
|
|
655
|
-
windowsHide: true,
|
|
656
|
-
env: spawnEnv,
|
|
657
|
-
cwd: workingDirectory || undefined,
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
else {
|
|
661
|
-
child = spawn(codexPath, cliArgs, {
|
|
662
|
-
stdio: ["pipe", logFd, logFd],
|
|
663
|
-
shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(codexPath),
|
|
664
|
-
detached: true,
|
|
665
|
-
windowsHide: true,
|
|
666
|
-
env: spawnEnv,
|
|
667
|
-
cwd: workingDirectory || undefined,
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
catch (err) {
|
|
672
|
-
closeSync(logFd);
|
|
673
|
-
return { error: `Failed to spawn Codex process: ${errorMessage(err)}` };
|
|
674
|
-
}
|
|
675
|
-
closeSync(logFd);
|
|
676
|
-
// Write prompt to stdin (codex reads it via the "-" arg) and close to signal EOF
|
|
677
|
-
try {
|
|
678
|
-
child.stdin?.write(prompt + "\n");
|
|
679
|
-
child.stdin?.end();
|
|
680
|
-
}
|
|
681
|
-
catch { /* process may have already exited */ }
|
|
682
|
-
return registerSpawnedProcess({
|
|
683
|
-
child, threadId, name, logFilePath,
|
|
684
|
-
configPath: CODEX_HOME_DIR,
|
|
685
|
-
agentLabel: "Codex",
|
|
686
|
-
memorySourceThreadId, threadType,
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
/**
|
|
690
|
-
* Read all PID files from the pids directory.
|
|
691
|
-
* Supports both legacy (plain PID number) and new (JSON metadata) formats.
|
|
692
|
-
*/
|
|
693
|
-
export function readPidFiles() {
|
|
694
|
-
const entries = [];
|
|
695
|
-
try {
|
|
696
|
-
const files = readdirSync(PIDS_DIR);
|
|
697
|
-
for (const file of files) {
|
|
698
|
-
if (!file.endsWith(".pid"))
|
|
699
|
-
continue;
|
|
700
|
-
try {
|
|
701
|
-
const threadId = Number(file.replace(".pid", ""));
|
|
702
|
-
const filePath = join(PIDS_DIR, file);
|
|
703
|
-
const raw = readFileSync(filePath, "utf-8").trim();
|
|
704
|
-
let pid;
|
|
705
|
-
let name;
|
|
706
|
-
try {
|
|
707
|
-
const meta = JSON.parse(raw);
|
|
708
|
-
pid = meta.pid;
|
|
709
|
-
name = meta.name;
|
|
710
|
-
}
|
|
711
|
-
catch {
|
|
712
|
-
// Legacy format: plain PID number
|
|
713
|
-
pid = Number(raw);
|
|
714
|
-
}
|
|
715
|
-
if (Number.isFinite(threadId) && Number.isFinite(pid)) {
|
|
716
|
-
entries.push({ threadId, pid, filePath, name });
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
catch { /* skip unreadable files */ }
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
catch { /* PIDS_DIR may not exist */ }
|
|
723
|
-
return entries;
|
|
724
|
-
}
|
|
725
|
-
// ---------------------------------------------------------------------------
|
|
726
|
-
// Stale PID cleanup
|
|
727
|
-
// ---------------------------------------------------------------------------
|
|
728
|
-
/**
|
|
729
|
-
* Remove PID files for processes that are no longer running.
|
|
730
|
-
* Called on server startup / before spawning new threads.
|
|
731
|
-
* Uses a single batch check instead of N sequential calls to avoid
|
|
732
|
-
* WMI/tasklist hangs multiplying across all tracked PIDs.
|
|
733
|
-
*/
|
|
734
|
-
export function cleanupStalePidFiles() {
|
|
735
|
-
const entries = readPidFiles();
|
|
736
|
-
if (entries.length === 0)
|
|
737
|
-
return;
|
|
738
|
-
const alivePids = getAlivePids(entries.map(e => e.pid));
|
|
739
|
-
for (const { pid, filePath } of entries) {
|
|
740
|
-
if (!alivePids.has(pid)) {
|
|
741
|
-
try {
|
|
742
|
-
unlinkSync(filePath);
|
|
743
|
-
log.info(`[cleanup] Removed stale PID file ${filePath} (pid ${pid})`);
|
|
744
|
-
}
|
|
745
|
-
catch { /* already removed */ }
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
/**
|
|
750
|
-
* Batch-check which PIDs are alive using process.kill(pid, 0).
|
|
751
|
-
* This is non-blocking and works cross-platform — no PowerShell or tasklist.
|
|
752
|
-
*/
|
|
753
|
-
function getAlivePids(pids) {
|
|
754
|
-
const alive = new Set();
|
|
755
|
-
for (const pid of pids) {
|
|
756
|
-
if (isProcessAlive(pid))
|
|
757
|
-
alive.add(pid);
|
|
758
|
-
}
|
|
759
|
-
return alive;
|
|
760
|
-
}
|
|
761
|
-
// ---------------------------------------------------------------------------
|
|
762
|
-
// Startup: spawn keepAlive threads
|
|
763
|
-
// ---------------------------------------------------------------------------
|
|
764
|
-
/**
|
|
765
|
-
* Kill any orphan agent processes from PID files and spawn fresh processes
|
|
766
|
-
* for all keepAlive threads in the registry. Called once on server startup.
|
|
767
|
-
*/
|
|
768
|
-
export function spawnKeepAliveThreads() {
|
|
769
|
-
const result = { spawned: 0, errors: [] };
|
|
770
|
-
startupCleanupInProgress = true;
|
|
771
|
-
let db;
|
|
772
|
-
try {
|
|
773
|
-
db = initMemoryDb();
|
|
774
|
-
}
|
|
775
|
-
catch (err) {
|
|
776
|
-
result.errors.push(`Failed to open DB: ${errorMessage(err)}`);
|
|
777
|
-
return result;
|
|
778
|
-
}
|
|
779
|
-
// Re-adopt surviving agents from previous server instance.
|
|
780
|
-
// With detached: true, agents survive server restarts. Re-register them
|
|
781
|
-
// in spawnedThreads so findAliveThread works, and skip re-spawning them.
|
|
782
|
-
const pidEntries = readPidFiles();
|
|
783
|
-
for (const { pid, filePath, threadId: pidThreadId, name: pidName } of pidEntries) {
|
|
784
|
-
if (isProcessAlive(pid)) {
|
|
785
|
-
log.info(`[startup] Re-adopting surviving agent PID=${pid} for thread ${pidThreadId}`);
|
|
786
|
-
spawnedThreads.push({
|
|
787
|
-
pid,
|
|
788
|
-
threadId: pidThreadId,
|
|
789
|
-
name: pidName ?? `thread-${pidThreadId}`,
|
|
790
|
-
startedAt: Date.now(),
|
|
791
|
-
createdAt: Date.now(),
|
|
792
|
-
logFile: "",
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
else {
|
|
796
|
-
// Process is dead — clean up the stale PID file
|
|
797
|
-
try {
|
|
798
|
-
unlinkSync(filePath);
|
|
799
|
-
}
|
|
800
|
-
catch { /* already removed */ }
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
// Find all keepAlive threads
|
|
804
|
-
const threads = db.prepare(`SELECT * FROM thread_registry WHERE keep_alive = 1 AND status = 'active'`).all();
|
|
805
|
-
if (threads.length === 0) {
|
|
806
|
-
startupCleanupInProgress = false;
|
|
807
|
-
return result;
|
|
808
|
-
}
|
|
809
|
-
// Cleanup complete — allow spawns from here on
|
|
810
|
-
startupCleanupInProgress = false;
|
|
811
|
-
ensureDirs();
|
|
812
|
-
for (const row of threads) {
|
|
813
|
-
const threadId = row.thread_id;
|
|
814
|
-
const name = row.name;
|
|
815
|
-
const client = row.client;
|
|
816
|
-
// Skip if already running (e.g. a concurrent startup or keeper already spawned it)
|
|
817
|
-
if (findAliveThread(threadId)) {
|
|
818
|
-
log.info(`[startup] Thread ${threadId} ("${name}") already running — skipping`);
|
|
819
|
-
continue;
|
|
820
|
-
}
|
|
821
|
-
// Determine agent type and resolve path
|
|
822
|
-
const isCopilot = client === "copilot" || client === "copilot_claude" || client === "copilot_codex";
|
|
823
|
-
const isCodex = client === "codex" || client === "openai_codex";
|
|
824
|
-
let spawnResult;
|
|
825
|
-
if (isCopilot) {
|
|
826
|
-
const cliPath = resolveCopilotPath();
|
|
827
|
-
if (!cliPath) {
|
|
828
|
-
result.errors.push(`Thread ${threadId} (${name}): copilot CLI not found`);
|
|
829
|
-
continue;
|
|
830
|
-
}
|
|
831
|
-
spawnResult = spawnCopilotProcess(cliPath, name, threadId, undefined, threadId, client);
|
|
832
|
-
}
|
|
833
|
-
else if (isCodex) {
|
|
834
|
-
const cliPath = resolveCodexPath();
|
|
835
|
-
if (!cliPath) {
|
|
836
|
-
result.errors.push(`Thread ${threadId} (${name}): codex CLI not found`);
|
|
837
|
-
continue;
|
|
838
|
-
}
|
|
839
|
-
spawnResult = spawnCodexProcess(cliPath, name, threadId, undefined, threadId);
|
|
840
|
-
}
|
|
841
|
-
else {
|
|
842
|
-
const cliPath = resolveClaudePath();
|
|
843
|
-
if (!cliPath) {
|
|
844
|
-
result.errors.push(`Thread ${threadId} (${name}): claude CLI not found`);
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
const mcpConfig = resolveMcpConfigPath();
|
|
848
|
-
if (!mcpConfig) {
|
|
849
|
-
result.errors.push(`Thread ${threadId} (${name}): MCP config not found`);
|
|
850
|
-
continue;
|
|
851
|
-
}
|
|
852
|
-
spawnResult = spawnAgentProcess(cliPath, mcpConfig, name, threadId, undefined, threadId, threadId);
|
|
853
|
-
}
|
|
854
|
-
if ("error" in spawnResult) {
|
|
855
|
-
result.errors.push(`Thread ${threadId} (${name}): ${spawnResult.error}`);
|
|
856
|
-
continue;
|
|
857
|
-
}
|
|
858
|
-
// Update lastActiveAt to mark the thread as recently started
|
|
859
|
-
try {
|
|
860
|
-
updateThread(db, threadId, { lastActiveAt: new Date().toISOString(), status: 'active' });
|
|
861
|
-
}
|
|
862
|
-
catch { /* best-effort */ }
|
|
863
|
-
log.info(`[startup] Spawned ${client} process PID=${spawnResult.pid} for keepAlive thread ${threadId} ("${name}")`);
|
|
864
|
-
result.spawned++;
|
|
865
|
-
}
|
|
866
|
-
return result;
|
|
867
|
-
}
|
|
868
|
-
const DEFAULT_WORKER_TTL_MS = 60 * 60 * 1000; // 60 minutes — one-shot threads auto-cleanup
|
|
869
|
-
/**
|
|
870
|
-
* Clean up expired worker threads.
|
|
871
|
-
* Workers are temporary threads (have memorySourceThreadId but no memoryTargetThreadId).
|
|
872
|
-
* After TTL expires: synthesize outcomes, kill process, delete PID, delete Telegram topic.
|
|
873
|
-
*/
|
|
874
|
-
export async function cleanupExpiredWorkers(db, telegram, chatId, ttlMs = DEFAULT_WORKER_TTL_MS) {
|
|
875
|
-
const result = { cleaned: 0, errors: [] };
|
|
876
|
-
const now = Date.now();
|
|
877
|
-
const isExpiredWorker = (t) => t.threadType === 'worker' && now - t.createdAt > ttlMs;
|
|
878
|
-
for (const thread of spawnedThreads.filter(isExpiredWorker)) {
|
|
879
|
-
try {
|
|
880
|
-
await cleanupSingleWorker(thread, db, telegram, chatId);
|
|
881
|
-
result.cleaned++;
|
|
882
|
-
}
|
|
883
|
-
catch (err) {
|
|
884
|
-
result.errors.push(`Thread ${thread.threadId}: ${errorMessage(err)}`);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
// Also clean stale workers from thread_registry (survives server restarts)
|
|
888
|
-
try {
|
|
889
|
-
const cutoff = new Date(now - ttlMs).toISOString();
|
|
890
|
-
const staleRows = db.prepare(`SELECT thread_id FROM thread_registry
|
|
891
|
-
WHERE type = 'worker' AND status IN ('active', 'exited') AND COALESCE(last_active_at, created_at) < ?`).all(cutoff);
|
|
892
|
-
for (const row of staleRows) {
|
|
893
|
-
// Skip if still alive in-memory (already handled above)
|
|
894
|
-
if (spawnedThreads.some(t => t.threadId === row.thread_id))
|
|
895
|
-
continue;
|
|
896
|
-
try {
|
|
897
|
-
// Delete Telegram topic for stale worker — only if it has an explicit topic
|
|
898
|
-
// (fallback to thread_id could accidentally delete a root's topic)
|
|
899
|
-
try {
|
|
900
|
-
const topicId = getExplicitTelegramTopicId(db, row.thread_id);
|
|
901
|
-
if (topicId != null)
|
|
902
|
-
await telegram.deleteForumTopic(chatId, topicId);
|
|
903
|
-
}
|
|
904
|
-
catch { /* topic might not exist */ }
|
|
905
|
-
archiveThread(db, row.thread_id);
|
|
906
|
-
try {
|
|
907
|
-
archiveNotesForThread(db, row.thread_id);
|
|
908
|
-
}
|
|
909
|
-
catch { /* best-effort */ }
|
|
910
|
-
result.cleaned++;
|
|
911
|
-
}
|
|
912
|
-
catch { /* best-effort */ }
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
catch { /* registry cleanup is best-effort */ }
|
|
916
|
-
return result;
|
|
917
|
-
}
|
|
918
|
-
/** Perform cleanup steps for a single expired worker thread. */
|
|
919
|
-
async function cleanupSingleWorker(thread, db, telegram, chatId) {
|
|
920
|
-
// 1. Synthesize before cleanup (best-effort)
|
|
921
|
-
if (thread.memorySourceThreadId !== undefined) {
|
|
922
|
-
try {
|
|
923
|
-
await synthesizeGhostMemory(db, thread.threadId, thread.memorySourceThreadId, thread.name);
|
|
924
|
-
}
|
|
925
|
-
catch { /* synthesis is best-effort */ }
|
|
926
|
-
}
|
|
927
|
-
// 2. Kill process
|
|
928
|
-
try {
|
|
929
|
-
process.kill(thread.pid, "SIGTERM");
|
|
930
|
-
}
|
|
931
|
-
catch { /* already dead */ }
|
|
932
|
-
// 3. Delete Telegram topic — only if worker has an explicit topic ID
|
|
933
|
-
try {
|
|
934
|
-
const topicId = getExplicitTelegramTopicId(db, thread.threadId);
|
|
935
|
-
if (topicId != null)
|
|
936
|
-
await telegram.deleteForumTopic(chatId, topicId);
|
|
937
|
-
}
|
|
938
|
-
catch { /* topic might not exist */ }
|
|
939
|
-
// 4. Archive in thread registry and expire semantic notes (best-effort)
|
|
940
|
-
try {
|
|
941
|
-
const db = initMemoryDb();
|
|
942
|
-
archiveThread(db, thread.threadId);
|
|
943
|
-
archiveNotesForThread(db, thread.threadId);
|
|
944
|
-
}
|
|
945
|
-
catch { /* registry archival is best-effort */ }
|
|
946
|
-
// 5. Remove from tracking (PID file cleanup happens in the exit handler)
|
|
947
|
-
const idx = spawnedThreads.indexOf(thread);
|
|
948
|
-
if (idx !== -1)
|
|
949
|
-
spawnedThreads.splice(idx, 1);
|
|
950
|
-
}
|
|
951
|
-
// ---------------------------------------------------------------------------
|
|
952
|
-
// Thread health monitoring
|
|
953
|
-
// ---------------------------------------------------------------------------
|
|
954
|
-
function formatRelativeTime(ms) {
|
|
955
|
-
if (ms < 0)
|
|
956
|
-
return "just now";
|
|
957
|
-
const seconds = Math.floor(ms / 1000);
|
|
958
|
-
if (seconds < 60)
|
|
959
|
-
return `${seconds}s ago`;
|
|
960
|
-
const minutes = Math.floor(seconds / 60);
|
|
961
|
-
if (minutes < 60)
|
|
962
|
-
return `${minutes}m ago`;
|
|
963
|
-
const hours = Math.floor(minutes / 60);
|
|
964
|
-
if (hours < 24)
|
|
965
|
-
return `${hours}h ago`;
|
|
966
|
-
return `${Math.floor(hours / 24)}d ago`;
|
|
967
|
-
}
|
|
968
|
-
function formatUptime(startedAt) {
|
|
969
|
-
const ms = Date.now() - startedAt;
|
|
970
|
-
const seconds = Math.floor(ms / 1000);
|
|
971
|
-
if (seconds < 60)
|
|
972
|
-
return `${seconds}s`;
|
|
973
|
-
const minutes = Math.floor(seconds / 60);
|
|
974
|
-
if (minutes < 60)
|
|
975
|
-
return `${minutes}m`;
|
|
976
|
-
const hours = Math.floor(minutes / 60);
|
|
977
|
-
if (hours < 24)
|
|
978
|
-
return `${hours}h ${minutes % 60}m`;
|
|
979
|
-
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
980
|
-
}
|
|
981
|
-
/**
|
|
982
|
-
* Gather thread data from all 4 sources (topic registry, dashboard sessions,
|
|
983
|
-
* in-memory spawned processes, PID files on disk) and return merged rows.
|
|
984
|
-
*/
|
|
985
|
-
function collectThreadData() {
|
|
986
|
-
const topicsByChat = getAllRegisteredTopics();
|
|
987
|
-
const sessions = getDashboardSessions();
|
|
988
|
-
const spawned = [...spawnedThreads];
|
|
989
|
-
const pidFiles = readPidFiles();
|
|
990
|
-
const now = Date.now();
|
|
991
|
-
// 5th source: persistent thread_registry DB
|
|
992
|
-
let registryByThread = new Map();
|
|
993
|
-
try {
|
|
994
|
-
const db = initMemoryDb();
|
|
995
|
-
const allRegistered = getAllThreads(db);
|
|
996
|
-
for (const entry of allRegistered)
|
|
997
|
-
registryByThread.set(entry.threadId, entry);
|
|
998
|
-
}
|
|
999
|
-
catch { /* DB unavailable — degrade gracefully */ }
|
|
1000
|
-
// Build Maps for O(1) lookups
|
|
1001
|
-
const spawnedByThread = new Map();
|
|
1002
|
-
for (const s of spawned)
|
|
1003
|
-
spawnedByThread.set(s.threadId, s);
|
|
1004
|
-
const pidByThread = new Map();
|
|
1005
|
-
for (const p of pidFiles)
|
|
1006
|
-
pidByThread.set(p.threadId, p.pid);
|
|
1007
|
-
// Build thread name map + collect threadIds from topic registry
|
|
1008
|
-
const threadNames = new Map();
|
|
1009
|
-
const allThreadIds = new Set();
|
|
1010
|
-
for (const chatTopics of Object.values(topicsByChat)) {
|
|
1011
|
-
for (const [name, threadId] of Object.entries(chatTopics)) {
|
|
1012
|
-
threadNames.set(threadId, name);
|
|
1013
|
-
allThreadIds.add(threadId);
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
// Add threadIds from other sources
|
|
1017
|
-
for (const s of sessions) {
|
|
1018
|
-
if (s.threadId != null)
|
|
1019
|
-
allThreadIds.add(s.threadId);
|
|
1020
|
-
}
|
|
1021
|
-
for (const s of spawned)
|
|
1022
|
-
allThreadIds.add(s.threadId);
|
|
1023
|
-
for (const p of pidFiles)
|
|
1024
|
-
allThreadIds.add(p.threadId);
|
|
1025
|
-
for (const id of registryByThread.keys()) {
|
|
1026
|
-
allThreadIds.add(id);
|
|
1027
|
-
if (!threadNames.has(id)) {
|
|
1028
|
-
const entry = registryByThread.get(id);
|
|
1029
|
-
if (entry?.name)
|
|
1030
|
-
threadNames.set(id, entry.name);
|
|
1031
|
-
}
|
|
1032
|
-
}
|
|
1033
|
-
// Group sessions by threadId for O(1) lookup
|
|
1034
|
-
const sessionsByThread = new Map();
|
|
1035
|
-
for (const s of sessions) {
|
|
1036
|
-
if (s.threadId == null)
|
|
1037
|
-
continue;
|
|
1038
|
-
const arr = sessionsByThread.get(s.threadId) ?? [];
|
|
1039
|
-
arr.push(s);
|
|
1040
|
-
sessionsByThread.set(s.threadId, arr);
|
|
1041
|
-
}
|
|
1042
|
-
const result = [];
|
|
1043
|
-
for (const threadId of allThreadIds) {
|
|
1044
|
-
const spawnedEntry = spawnedByThread.get(threadId);
|
|
1045
|
-
const pid = spawnedEntry?.pid ?? pidByThread.get(threadId);
|
|
1046
|
-
const alive = pid !== undefined && isProcessAlive(pid);
|
|
1047
|
-
const threadSessions = sessionsByThread.get(threadId) ?? [];
|
|
1048
|
-
const activeSession = threadSessions.find(s => s.status === "active");
|
|
1049
|
-
const anySession = activeSession ?? threadSessions[0];
|
|
1050
|
-
const hasRecentWait = anySession?.lastWaitCallAt != null
|
|
1051
|
-
&& (now - anySession.lastWaitCallAt) < WAIT_LIVENESS_MS;
|
|
1052
|
-
const regEntry = registryByThread.get(threadId);
|
|
1053
|
-
const regLastActive = regEntry?.lastActiveAt
|
|
1054
|
-
? new Date(regEntry.lastActiveAt).getTime()
|
|
1055
|
-
: undefined;
|
|
1056
|
-
result.push({
|
|
1057
|
-
threadId,
|
|
1058
|
-
name: threadNames.get(threadId) ?? regEntry?.name ?? "unnamed",
|
|
1059
|
-
pid,
|
|
1060
|
-
alive,
|
|
1061
|
-
hasActiveSession: !!activeSession,
|
|
1062
|
-
hasRecentWait,
|
|
1063
|
-
sessionCount: threadSessions.length,
|
|
1064
|
-
lastActivity: anySession?.lastActivity ?? regLastActive,
|
|
1065
|
-
spawnedStartedAt: spawnedEntry?.startedAt,
|
|
1066
|
-
registryStatus: regEntry?.status,
|
|
1067
|
-
keepAlive: regEntry?.keepAlive ?? false,
|
|
1068
|
-
registryLastActive: regLastActive,
|
|
1069
|
-
});
|
|
1070
|
-
}
|
|
1071
|
-
return result;
|
|
1072
|
-
}
|
|
1073
|
-
/**
|
|
1074
|
-
* Classify a thread as running / dormant / dead / unknown based on its
|
|
1075
|
-
* process liveness and dashboard-session state.
|
|
1076
|
-
*
|
|
1077
|
-
* Session activity is checked independently of PID — the main server thread
|
|
1078
|
-
* (e.g. sensorium) is the host process itself and won't appear in
|
|
1079
|
-
* spawnedThreads, yet it has an active dashboard session with recent
|
|
1080
|
-
* wait-call activity.
|
|
1081
|
-
*/
|
|
1082
|
-
function classifyThreadStatus(t) {
|
|
1083
|
-
if (t.alive && t.hasActiveSession && t.hasRecentWait)
|
|
1084
|
-
return "running";
|
|
1085
|
-
// Session-only liveness: no PID tracked but dashboard session is active
|
|
1086
|
-
if (t.hasActiveSession && t.hasRecentWait)
|
|
1087
|
-
return "running";
|
|
1088
|
-
if (t.hasActiveSession)
|
|
1089
|
-
return "dormant";
|
|
1090
|
-
if (t.alive)
|
|
1091
|
-
return "dormant";
|
|
1092
|
-
if (t.pid !== undefined && !t.alive)
|
|
1093
|
-
return "dead";
|
|
1094
|
-
// Use persistent registry status when ephemeral sources have no data
|
|
1095
|
-
if (t.registryStatus === "archived" || t.registryStatus === "expired")
|
|
1096
|
-
return "dead";
|
|
1097
|
-
if (t.registryStatus === "exited")
|
|
1098
|
-
return "dead";
|
|
1099
|
-
if (t.registryStatus === "active" && t.keepAlive)
|
|
1100
|
-
return "dormant";
|
|
1101
|
-
if (t.registryStatus === "active")
|
|
1102
|
-
return "dormant";
|
|
1103
|
-
return "unknown";
|
|
1104
|
-
}
|
|
1105
|
-
// ---------------------------------------------------------------------------
|
|
1106
|
-
// Public API
|
|
1107
|
-
// ---------------------------------------------------------------------------
|
|
1108
|
-
/**
|
|
1109
|
-
* Get comprehensive health status of all known threads.
|
|
1110
|
-
* Merges data from topic registry, dashboard sessions, spawned processes,
|
|
1111
|
-
* and PID files on disk. Returns a formatted markdown table.
|
|
1112
|
-
*/
|
|
1113
|
-
export function getThreadsHealth() {
|
|
1114
|
-
const threads = collectThreadData();
|
|
1115
|
-
if (threads.length === 0) {
|
|
1116
|
-
return "No threads found. No topics registered, no active sessions, no PID files.";
|
|
1117
|
-
}
|
|
1118
|
-
const now = Date.now();
|
|
1119
|
-
const rows = threads.map(t => {
|
|
1120
|
-
const status = classifyThreadStatus(t);
|
|
1121
|
-
const safeName = t.name.replace(/\|/g, "\\|");
|
|
1122
|
-
let sessionStr = "-";
|
|
1123
|
-
if (t.hasActiveSession)
|
|
1124
|
-
sessionStr = "active";
|
|
1125
|
-
else if (t.sessionCount > 0)
|
|
1126
|
-
sessionStr = "disconnected";
|
|
1127
|
-
let lastActivityStr = "-";
|
|
1128
|
-
if (t.lastActivity)
|
|
1129
|
-
lastActivityStr = formatRelativeTime(now - t.lastActivity);
|
|
1130
|
-
let uptimeStr = "-";
|
|
1131
|
-
if (t.spawnedStartedAt && t.alive)
|
|
1132
|
-
uptimeStr = formatUptime(t.spawnedStartedAt);
|
|
1133
|
-
return {
|
|
1134
|
-
threadId: t.threadId,
|
|
1135
|
-
name: safeName,
|
|
1136
|
-
status,
|
|
1137
|
-
pid: t.pid !== undefined ? String(t.pid) : "-",
|
|
1138
|
-
lastActivity: lastActivityStr,
|
|
1139
|
-
session: sessionStr,
|
|
1140
|
-
uptime: uptimeStr,
|
|
1141
|
-
};
|
|
1142
|
-
});
|
|
1143
|
-
// Sort: running first, then dormant, dead, unknown
|
|
1144
|
-
const statusOrder = { running: 0, dormant: 1, dead: 2, unknown: 3 };
|
|
1145
|
-
rows.sort((a, b) => (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9));
|
|
1146
|
-
// Format markdown table
|
|
1147
|
-
const lines = [];
|
|
1148
|
-
lines.push("## Thread Health Report");
|
|
1149
|
-
lines.push("");
|
|
1150
|
-
lines.push("| Thread ID | Name | Status | PID | Last Activity | Session | Uptime |");
|
|
1151
|
-
lines.push("|-----------|------|--------|-----|---------------|---------|--------|");
|
|
1152
|
-
for (const r of rows) {
|
|
1153
|
-
lines.push(`| ${r.threadId} | ${r.name} | ${r.status} | ${r.pid} | ${r.lastActivity} | ${r.session} | ${r.uptime} |`);
|
|
1154
|
-
}
|
|
1155
|
-
// Summary
|
|
1156
|
-
const running = rows.filter(r => r.status === "running").length;
|
|
1157
|
-
const dormant = rows.filter(r => r.status === "dormant").length;
|
|
1158
|
-
const dead = rows.filter(r => r.status === "dead").length;
|
|
1159
|
-
const unknown = rows.filter(r => r.status === "unknown").length;
|
|
1160
|
-
lines.push("");
|
|
1161
|
-
lines.push(`**Summary:** ${rows.length} threads -- ${running} running, ${dormant} dormant, ${dead} dead, ${unknown} unknown`);
|
|
1162
|
-
return lines.join("\n");
|
|
1163
|
-
}
|
|
1
|
+
export * from "../services/process.service.js";
|
|
2
|
+
export * from "../services/agent-spawn.service.js";
|
|
3
|
+
export * from "../services/thread-health.service.js";
|
|
4
|
+
export * from "../services/topic.service.js";
|
|
5
|
+
export * from "../services/worker-cleanup.service.js";
|
|
1164
6
|
//# sourceMappingURL=thread-lifecycle.js.map
|