@lifeaitools/clauth 2.1.1 → 2.10.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.
Files changed (98) hide show
  1. package/.clauth-skill/references/keys-guide.md +270 -270
  2. package/cli/api.js +238 -238
  3. package/cli/commands/install.js +413 -396
  4. package/cli/commands/login.test.js +49 -1
  5. package/cli/commands/ops-install.js +11 -10
  6. package/cli/commands/ops.test.js +270 -0
  7. package/cli/commands/scrub.test.js +70 -0
  8. package/cli/commands/serve.js +2618 -11238
  9. package/cli/commands/uninstall.js +164 -164
  10. package/cli/dashboard/dashboard.js +592 -0
  11. package/cli/dashboard/global.css +65 -0
  12. package/cli/dashboard/panel-element.js +79 -0
  13. package/cli/dashboard/panels/build-status-panel.css +12 -0
  14. package/cli/dashboard/panels/build-status-panel.js +86 -0
  15. package/cli/dashboard/panels/panel-registry.js +58 -0
  16. package/cli/dashboard/panels/services-panel.css +95 -0
  17. package/cli/dashboard/panels/services-panel.js +872 -0
  18. package/cli/dashboard/panels/surfaces-panel.css +59 -0
  19. package/cli/dashboard/panels/surfaces-panel.js +474 -0
  20. package/cli/dashboard/panels/toolbar-panel.css +42 -0
  21. package/cli/dashboard/panels/toolbar-panel.js +374 -0
  22. package/cli/dashboard/panels/tunnel-panel.css +61 -0
  23. package/cli/dashboard/panels/tunnel-panel.js +688 -0
  24. package/cli/dashboard/panels/webdav-panel.css +38 -0
  25. package/cli/dashboard/panels/webdav-panel.js +166 -0
  26. package/cli/fingerprint.test.js +95 -0
  27. package/cli/http/components/callagent-terminal-component.js +310 -0
  28. package/cli/http/components/dashboard-component.js +123 -0
  29. package/cli/http/components/inbox-component.js +41 -0
  30. package/cli/http/components/mcp-transport-component.js +275 -0
  31. package/cli/http/components/oauth-component.js +207 -0
  32. package/cli/http/components/service-crud-component.js +288 -0
  33. package/cli/http/components/supervisor-component.js +104 -0
  34. package/cli/http/components/system-component.js +184 -0
  35. package/cli/http/components/tunnel-component.js +508 -0
  36. package/cli/http/components/vault-auth-component.js +264 -0
  37. package/cli/http/components/watchdog-component.js +28 -0
  38. package/cli/http/request-utils.js +21 -0
  39. package/cli/http/servers/ops-server.js +335 -0
  40. package/cli/index.js +5 -91
  41. package/cli/lib/fs-git.test.js +467 -0
  42. package/cli/mcp/providers/call-agent-provider.js +30 -0
  43. package/cli/mcp/providers/clauth-core-provider.js +461 -0
  44. package/cli/mcp/providers/ops-provider.js +42 -0
  45. package/cli/mcp/providers/terminal-provider.js +81 -0
  46. package/cli/mcp/tool-registry.js +48 -0
  47. package/cli/mcp/tool-schemas.js +822 -0
  48. package/cli/mcp/tool-schemas.test.js +49 -0
  49. package/cli/ops/pm2-preflight.js +54 -0
  50. package/cli/recovery.js +6 -0
  51. package/cli/recovery.test.js +179 -0
  52. package/cli/services/agent-pool-instance.js +38 -0
  53. package/cli/services/claude-binary.js +29 -0
  54. package/cli/services/delegation-lane-instance.js +35 -0
  55. package/cli/services/delegation-lane-instance.test.js +37 -0
  56. package/cli/services/file-io.js +38 -0
  57. package/cli/services/file-io.test.js +10 -0
  58. package/cli/services/fs-mount.js +66 -0
  59. package/cli/services/fs-mount.test.js +14 -0
  60. package/cli/services/fs-upload-sessions.js +20 -0
  61. package/cli/services/fs-upload-sessions.test.js +14 -0
  62. package/cli/services/git-exec.js +37 -0
  63. package/cli/services/git-exec.test.js +41 -0
  64. package/cli/services/git-import-guard.js +28 -0
  65. package/cli/services/git-import-guard.test.js +18 -0
  66. package/cli/services/service-status.js +191 -0
  67. package/cli/services/service-status.test.js +39 -0
  68. package/cli/services/terminal-dispatch.js +135 -0
  69. package/cli/services/terminal-dispatch.test.js +9 -0
  70. package/cli/services/terminal-registry.js +39 -0
  71. package/cli/services/terminal-registry.test.js +31 -0
  72. package/cli/services/terminal-sessions.js +100 -0
  73. package/cli/services/terminal-sessions.test.js +26 -0
  74. package/cli/services/terminal-window.js +106 -0
  75. package/cli/services/vault-read.js +14 -0
  76. package/cli/services/vault-read.test.js +35 -0
  77. package/cli/supervisor-registry.js +116 -0
  78. package/cli/supervisor-registry.test.js +165 -0
  79. package/cli/supervisor-ui.test.js +230 -30
  80. package/cli/webdav-config.js +84 -0
  81. package/cli/webdav-config.test.js +79 -0
  82. package/cli/webdav-obscure.js +23 -0
  83. package/cli/webdav-obscure.test.js +41 -0
  84. package/cli/webdav-rclone-discovery.js +44 -0
  85. package/cli/webdav-service.js +30 -118
  86. package/cli/webdav-service.test.js +107 -0
  87. package/install.ps1 +102 -102
  88. package/install.sh +49 -49
  89. package/package.json +2 -2
  90. package/scripts/bootstrap.cjs +121 -121
  91. package/supabase/functions/auth-vault/index.ts +350 -350
  92. package/supabase/migrations/001_clauth_schema.sql +94 -94
  93. package/supabase/migrations/002_vault_helpers.sql +90 -90
  94. package/supabase/migrations/20260317_lockout.sql +26 -26
  95. package/cli/assets/codevelop/launcher-active.cmd.template +0 -20
  96. package/cli/assets/codevelop/launcher-static.cmd.template +0 -7
  97. package/cli/assets/codevelop/windows-terminal.profiles.json +0 -48
  98. package/cli/commands/codevelop.js +0 -1190
@@ -0,0 +1,41 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ import { runGit, runGitRaw } from "./git-exec.js";
9
+
10
+ function withRepo(fn) {
11
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-git-exec-test-"));
12
+ execFileSync("git", ["init", "-q"], { cwd: dir });
13
+ execFileSync("git", ["config", "user.email", "t@t.com"], { cwd: dir });
14
+ execFileSync("git", ["config", "user.name", "t"], { cwd: dir });
15
+ execFileSync("git", ["commit", "-q", "--allow-empty", "-m", "first"], { cwd: dir });
16
+ try { return fn(dir); } finally { fs.rmSync(dir, { recursive: true, force: true }); }
17
+ }
18
+
19
+ test("runGit returns trimmed stdout on success", () =>
20
+ withRepo((dir) => {
21
+ const branch = runGit(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
22
+ assert.equal(typeof branch, "string");
23
+ assert.ok(branch.length > 0, "must resolve to a real branch name, not an empty string");
24
+ assert.equal(branch, branch.trim(), "must be trimmed — no trailing newline from git's own stdout");
25
+ }));
26
+
27
+ test("runGit throws with git's stderr on a non-zero exit", () =>
28
+ withRepo((dir) => {
29
+ assert.throws(() => runGit(dir, ["rev-parse", "--verify", "refs/heads/does-not-exist"]), /failed/);
30
+ }));
31
+
32
+ test("runGitRaw returns a Buffer, not a string", () =>
33
+ withRepo((dir) => {
34
+ const out = runGitRaw(dir, ["rev-parse", "HEAD"]);
35
+ assert.ok(Buffer.isBuffer(out));
36
+ }));
37
+
38
+ test("runGitRaw throws on a non-zero exit, same as runGit", () =>
39
+ withRepo((dir) => {
40
+ assert.throws(() => runGitRaw(dir, ["rev-parse", "--verify", "refs/heads/does-not-exist"]), /failed/);
41
+ }));
@@ -0,0 +1,28 @@
1
+ // cli/services/git-import-guard.js
2
+ // Path-safety guards for fs_import_git_files: normalize a repo-relative path
3
+ // (reject traversal/absolute), and check it against the allowed-prefix
4
+ // allowlist. Split out of fs-mount.js for SRP — this is a path-validation
5
+ // concern, not a filesystem-mount concern.
6
+
7
+ import path from "node:path";
8
+
9
+ export const FS_GIT_IMPORT_ALLOWED_PREFIXES = [
10
+ "docs/",
11
+ ".rdc/plans/",
12
+ ".rdc/guides/",
13
+ ".claude/context/",
14
+ ".claude/rules/",
15
+ ".rdc/relay/from-claude-ai/",
16
+ ];
17
+
18
+ export function normalizeRepoPath(p) {
19
+ if (!p || typeof p !== "string") return null;
20
+ const normalized = p.replace(/\\/g, "/").replace(/^\/+/, "");
21
+ const parts = normalized.split("/").filter(Boolean);
22
+ if (parts.length === 0 || parts.includes("..") || path.isAbsolute(p)) return null;
23
+ return parts.join("/");
24
+ }
25
+
26
+ export function isAllowedGitImportPath(p, allowedPrefixes = FS_GIT_IMPORT_ALLOWED_PREFIXES) {
27
+ return allowedPrefixes.some((prefix) => p === prefix.replace(/\/$/, "") || p.startsWith(prefix));
28
+ }
@@ -0,0 +1,18 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { normalizeRepoPath, isAllowedGitImportPath, FS_GIT_IMPORT_ALLOWED_PREFIXES } from "./git-import-guard.js";
5
+
6
+ test("normalizeRepoPath rejects absolute paths and path traversal", () => {
7
+ assert.equal(normalizeRepoPath("docs/README.md"), "docs/README.md");
8
+ assert.equal(normalizeRepoPath("../outside.txt"), null);
9
+ assert.equal(normalizeRepoPath("C:/Dev/regen-root/x"), null, "an absolute path must be rejected, not silently accepted");
10
+ assert.equal(normalizeRepoPath(""), null);
11
+ });
12
+
13
+ test("isAllowedGitImportPath only allows the declared prefix set by default", () => {
14
+ assert.equal(isAllowedGitImportPath("docs/README.md"), true);
15
+ assert.equal(isAllowedGitImportPath(".rdc/plans/foo.md"), true);
16
+ assert.equal(isAllowedGitImportPath("apps/lifeai/secret.env"), false);
17
+ assert.ok(FS_GIT_IMPORT_ALLOWED_PREFIXES.includes("docs/"));
18
+ });
@@ -0,0 +1,191 @@
1
+ // cli/services/service-status.js
2
+ // Cached vault service-status lookup + text/address search over it.
3
+ // Shared by both the HTTP routes (/status, /search, /knowledge) and the MCP
4
+ // tools (clauth_status, clauth_search, clauth_knowledge) in serve.js — one
5
+ // module, one cache, one reason to change.
6
+
7
+ import { deriveToken } from "../fingerprint.js";
8
+ import * as api from "../api.js";
9
+ export const ENV_MAP = {
10
+ "github": "GITHUB_TOKEN",
11
+ "supabase-anon": "NEXT_PUBLIC_SUPABASE_ANON_KEY",
12
+ "supabase-service": "SUPABASE_SERVICE_ROLE_KEY",
13
+ "supabase-db": "SUPABASE_DB_URL",
14
+ "vercel": "VERCEL_TOKEN",
15
+ "anthropic": "ANTHROPIC_API_KEY",
16
+ "cloudflare": "CLOUDFLARE_API_TOKEN",
17
+ "r2": "R2_ACCESS_KEY_ID",
18
+ "r2-bucket": "R2_BUCKET_NAME",
19
+ "neo4j": "NEO4J_URI",
20
+ "rocketreach": "ROCKETREACH_API_KEY",
21
+ "npm": "NPM_TOKEN",
22
+ "namecheap": "NAMECHEAP_API_KEY",
23
+ "gmail": "GMAIL_CREDENTIALS",
24
+ };
25
+
26
+ const ADDRESS_KEY_TYPES = new Set(["connstring", "fileserver", "oauth"]);
27
+ const ADDRESS_FIELDS = new Set(["url", "uri", "host", "hostname", "server", "address", "base_url", "endpoint", "path", "root"]);
28
+ const SERVICE_STATUS_CACHE_TTL_MS = 30_000;
29
+ const serviceStatusCache = new Map();
30
+
31
+ function serviceStatusCacheKey(machineHash, project) {
32
+ return `${machineHash || "unknown"}::${project || ""}`;
33
+ }
34
+
35
+ function cloneJson(value) {
36
+ return JSON.parse(JSON.stringify(value));
37
+ }
38
+
39
+ export function invalidateServiceStatusCache(machineHash) {
40
+ const prefix = `${machineHash || "unknown"}::`;
41
+ for (const key of serviceStatusCache.keys()) {
42
+ if (key.startsWith(prefix)) serviceStatusCache.delete(key);
43
+ }
44
+ }
45
+
46
+ export function sanitizeServiceMetadata(service) {
47
+ return {
48
+ name: service.name,
49
+ label: service.label || null,
50
+ key_type: service.key_type || null,
51
+ enabled: !!service.enabled,
52
+ has_key: !!(service.has_key ?? service.vault_key),
53
+ last_retrieved: service.last_retrieved || null,
54
+ last_rotated: service.last_rotated || null,
55
+ created_at: service.created_at || null,
56
+ project: service.project || null,
57
+ description: service.description || null,
58
+ };
59
+ }
60
+
61
+ export async function getCachedServiceStatus({ password, machineHash, token, timestamp, project, whitelist, force = false }) {
62
+ if (!password) return { error: "Vault is locked" };
63
+ const key = serviceStatusCacheKey(machineHash, project);
64
+ const now = Date.now();
65
+ let cached = serviceStatusCache.get(key);
66
+
67
+ if (!force && cached && now - cached.at < SERVICE_STATUS_CACHE_TTL_MS) {
68
+ const payload = cloneJson(cached.payload);
69
+ if (whitelist) payload.services = (payload.services || []).filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
70
+ payload.cached = true;
71
+ payload.cache_ttl_ms = SERVICE_STATUS_CACHE_TTL_MS;
72
+ return payload;
73
+ }
74
+
75
+ const auth = token && timestamp ? { token, timestamp } : deriveToken(password, machineHash);
76
+ const result = await api.status(password, machineHash, auth.token, auth.timestamp, project);
77
+ if (result.error) return result;
78
+
79
+ const payload = {
80
+ ...result,
81
+ services: result.services || [],
82
+ };
83
+ serviceStatusCache.set(key, { at: now, payload: cloneJson(payload) });
84
+
85
+ const output = cloneJson(payload);
86
+ if (whitelist) output.services = (output.services || []).filter(s => whitelist.includes(String(s.name || "").toLowerCase()));
87
+ output.cached = false;
88
+ output.cache_ttl_ms = SERVICE_STATUS_CACHE_TTL_MS;
89
+ return output;
90
+ }
91
+
92
+ function normalizeSearchText(value) {
93
+ return String(value || "").toLowerCase();
94
+ }
95
+
96
+ function redactUrlish(value) {
97
+ const text = String(value || "").trim();
98
+ if (!text) return "";
99
+ try {
100
+ const url = new URL(text);
101
+ if (url.username) url.username = "***";
102
+ if (url.password) url.password = "***";
103
+ return url.toString();
104
+ } catch {
105
+ return text.replace(/:\/\/([^:@/\s]+):([^@/\s]+)@/g, "://***:***@");
106
+ }
107
+ }
108
+
109
+ function collectAddressHints(value, keyType) {
110
+ if (!ADDRESS_KEY_TYPES.has(String(keyType || "").toLowerCase())) return [];
111
+ const hints = new Set();
112
+
113
+ function add(candidate) {
114
+ if (candidate === undefined || candidate === null) return;
115
+ const text = redactUrlish(candidate);
116
+ if (text) hints.add(text);
117
+ }
118
+
119
+ function walk(node, fieldName = "") {
120
+ if (node === undefined || node === null) return;
121
+ if (typeof node === "string") {
122
+ if (fieldName && ADDRESS_FIELDS.has(fieldName.toLowerCase())) add(node);
123
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(node) || /^[A-Za-z]:[\\/]/.test(node) || node.startsWith("\\\\")) add(node);
124
+ return;
125
+ }
126
+ if (Array.isArray(node)) {
127
+ for (const item of node) walk(item, fieldName);
128
+ return;
129
+ }
130
+ if (typeof node === "object") {
131
+ for (const [key, child] of Object.entries(node)) walk(child, key);
132
+ }
133
+ }
134
+
135
+ try {
136
+ walk(JSON.parse(value));
137
+ } catch {
138
+ walk(value);
139
+ }
140
+
141
+ return [...hints];
142
+ }
143
+
144
+ export async function searchServices({ password, machineHash, token, timestamp, query, project, includeAddresses = false, whitelist, forceStatusRefresh = false }) {
145
+ const q = normalizeSearchText(query);
146
+ if (!q) return { error: "query is required" };
147
+
148
+ const result = await getCachedServiceStatus({ password, machineHash, token, timestamp, project, whitelist, force: forceStatusRefresh });
149
+ if (result.error) return { error: result.error };
150
+
151
+ let services = result.services || [];
152
+
153
+ const matches = [];
154
+ for (const s of services) {
155
+ const fields = {
156
+ name: s.name,
157
+ label: s.label,
158
+ project: s.project,
159
+ type: s.key_type,
160
+ description: s.description
161
+ };
162
+ const matched = Object.entries(fields)
163
+ .filter(([, value]) => normalizeSearchText(value).includes(q))
164
+ .map(([field]) => field);
165
+
166
+ let addressHints = [];
167
+ if (includeAddresses && ADDRESS_KEY_TYPES.has(String(s.key_type || "").toLowerCase()) && (s.has_key || s.vault_key)) {
168
+ const secret = await api.retrieve(password, machineHash, token, timestamp, s.name);
169
+ if (!secret.error) {
170
+ addressHints = collectAddressHints(secret.value, s.key_type);
171
+ if (addressHints.some(h => normalizeSearchText(h).includes(q))) matched.push("address");
172
+ }
173
+ }
174
+
175
+ if (matched.length) {
176
+ matches.push({
177
+ name: s.name,
178
+ label: s.label || null,
179
+ project: s.project || null,
180
+ key_type: s.key_type,
181
+ enabled: !!s.enabled,
182
+ has_key: !!(s.has_key || s.vault_key),
183
+ description: s.description || null,
184
+ matched: [...new Set(matched)],
185
+ address_hints: addressHints
186
+ });
187
+ }
188
+ }
189
+
190
+ return { query, count: matches.length, matches };
191
+ }
@@ -0,0 +1,39 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ ENV_MAP,
6
+ invalidateServiceStatusCache,
7
+ sanitizeServiceMetadata,
8
+ getCachedServiceStatus,
9
+ searchServices,
10
+ } from "./service-status.js";
11
+
12
+ test("sanitizeServiceMetadata strips a service down to safe display fields", () => {
13
+ const out = sanitizeServiceMetadata({
14
+ name: "github", label: "GitHub", key_type: "token", enabled: true,
15
+ vault_key: "some-internal-key-name", last_retrieved: "2026-01-01T00:00:00Z",
16
+ });
17
+ assert.equal(out.name, "github");
18
+ assert.equal(out.has_key, true, "has_key must derive from vault_key when has_key itself is absent");
19
+ assert.equal(out.vault_key, undefined, "the raw vault key name must never appear in sanitized metadata");
20
+ });
21
+
22
+ test("getCachedServiceStatus refuses without a password rather than hitting the vault", async () => {
23
+ const result = await getCachedServiceStatus({ password: null, machineHash: "h" });
24
+ assert.equal(result.error, "Vault is locked");
25
+ });
26
+
27
+ test("invalidateServiceStatusCache is a safe no-op with no cache entries yet", () => {
28
+ assert.doesNotThrow(() => invalidateServiceStatusCache("some-machine-hash"));
29
+ });
30
+
31
+ test("searchServices requires a non-empty query", async () => {
32
+ const result = await searchServices({ password: "pw", machineHash: "h", query: "" });
33
+ assert.equal(result.error, "query is required");
34
+ });
35
+
36
+ test("ENV_MAP names the expected env var for well-known services", () => {
37
+ assert.equal(ENV_MAP.github, "GITHUB_TOKEN");
38
+ assert.equal(ENV_MAP.anthropic, "ANTHROPIC_API_KEY");
39
+ });
@@ -0,0 +1,135 @@
1
+ // cli/services/terminal-dispatch.js
2
+ // Sends a message into a terminal session (warm-pool dispatch or cold spawn
3
+ // fallback) and reads back the response. Split out of terminal-sessions.js --
4
+ // the SOLID scorer flagged that file's original shape as mixing this
5
+ // message-dispatch concern with shared-registry state, session lifecycle, and
6
+ // Windows-window control.
7
+
8
+ import { spawn as spawnProc } from "node:child_process";
9
+ import { getAgentPool } from "./agent-pool-instance.js";
10
+ import { findClaudeBinary, CHITCHAT_FALLBACK_CWD } from "./claude-binary.js";
11
+ import { terminalSessions } from "./terminal-registry.js";
12
+
13
+ export function sendTerminalMessage(session_id, message) {
14
+ const session = terminalSessions.get(session_id);
15
+ if (!session) return { error: 'not_found', message: `Session ${session_id} not found` };
16
+ if (session.status === 'stopped') return { error: 'stopped', message: 'Session is stopped' };
17
+ if (session.status === 'busy') return { error: 'session_busy', message: 'Session is busy — try again shortly' };
18
+
19
+ const nextTurn = (session.turn || 0) + 1;
20
+ session.lastResponse = undefined;
21
+ session.lastResponseAt = null;
22
+ session.pendingTurn = nextTurn;
23
+
24
+ // ── Warm-pool path: dispatch to the pinned worker (maintains its own context) ──
25
+ if (session.warmWorker && !session.warmWorker.isDead()) {
26
+ session.status = 'busy';
27
+ const pool = getAgentPool();
28
+ pool.dispatchToSession(session_id, { prompt: message })
29
+ .then((result) => {
30
+ if (!terminalSessions.has(session_id)) return;
31
+ const s = terminalSessions.get(session_id);
32
+ const response = result.ok
33
+ ? (result.package || '').trim() || '(no output)'
34
+ : `(warm dispatch error: ${result.error || 'unknown'})`;
35
+ // Still update rolling context for terminal_recv compatibility
36
+ const turn = `\n\nUser: ${message}\nAssistant: ${response}`;
37
+ const combined = s.context + turn;
38
+ s.context = combined.length > 8000 ? combined.slice(combined.length - 8000) : combined;
39
+ s.lastResponse = response;
40
+ s.lastResponseAt = new Date().toISOString();
41
+ s.turn = s.pendingTurn || ((s.turn || 0) + 1);
42
+ s.pendingTurn = null;
43
+ s.status = 'ready';
44
+ s.activeProc = null;
45
+ console.log(`[terminal] session ${session_id} turn=${s.turn} complete (warm) ok=${result.ok}`);
46
+
47
+ // If the warm worker died during dispatch, clear it so next turn falls back to cold
48
+ if (!result.ok && result.error === 'pinned_worker_died') {
49
+ s.warmWorker = null;
50
+ console.log(`[terminal] session ${session_id} warm worker died, next turn will use cold path`);
51
+ }
52
+ })
53
+ .catch((err) => {
54
+ if (!terminalSessions.has(session_id)) return;
55
+ const s = terminalSessions.get(session_id);
56
+ s.lastResponse = `(warm dispatch failed: ${err.message})`;
57
+ s.lastResponseAt = new Date().toISOString();
58
+ s.turn = s.pendingTurn || ((s.turn || 0) + 1);
59
+ s.pendingTurn = null;
60
+ s.status = 'ready';
61
+ s.activeProc = null;
62
+ s.warmWorker = null; // fall back to cold on next turn
63
+ console.log(`[terminal] session ${session_id} warm dispatch threw, falling back to cold: ${err.message}`);
64
+ });
65
+ return { queued: true, session_id, warm: true, turn: nextTurn };
66
+ }
67
+
68
+ // ── Cold-spawn fallback: original path ──
69
+ const binary = findClaudeBinary();
70
+ if (!binary) return { error: 'binary_not_found', message: 'claude CLI not found in PATH or AppData/npm' };
71
+
72
+ session.status = 'busy';
73
+ const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(binary);
74
+ const command = isCmdShim ? "cmd" : binary;
75
+ const args = isCmdShim
76
+ ? ["/d", "/s", "/c", `"${binary}"`, "-p", message, "--system-prompt", session.context, "--setting-sources", "user", "--dangerously-skip-permissions"]
77
+ : ["-p", message, "--system-prompt", session.context, "--setting-sources", "user", "--dangerously-skip-permissions"];
78
+ const proc = spawnProc(command, args, {
79
+ cwd: session.cwd || CHITCHAT_FALLBACK_CWD,
80
+ env: process.env,
81
+ stdio: ['ignore', 'pipe', 'pipe'],
82
+ shell: false,
83
+ windowsHide: true,
84
+ });
85
+ session.activeProc = proc;
86
+
87
+ let stdout = '';
88
+ let stderr = '';
89
+ proc.stdout.on('data', d => { stdout += d; });
90
+ proc.stderr.on('data', d => { stderr += d; });
91
+ proc.on('close', (code) => {
92
+ if (terminalSessions.has(session_id)) {
93
+ const s = terminalSessions.get(session_id);
94
+ const response = stdout.trim() || stderr.trim() || '(no output)';
95
+ // Append turn to rolling context (cap at ~8000 chars to avoid overflow)
96
+ const turn = `\n\nUser: ${message}\nAssistant: ${response}`;
97
+ const combined = s.context + turn;
98
+ s.context = combined.length > 8000 ? combined.slice(combined.length - 8000) : combined;
99
+ s.lastResponse = response;
100
+ s.lastResponseAt = new Date().toISOString();
101
+ s.turn = s.pendingTurn || ((s.turn || 0) + 1);
102
+ s.pendingTurn = null;
103
+ s.status = 'ready';
104
+ s.activeProc = null;
105
+ console.log(`[terminal] session ${session_id} turn=${s.turn} complete code=${code}`);
106
+ }
107
+ });
108
+
109
+ return { queued: true, session_id, warm: false, turn: nextTurn };
110
+ }
111
+
112
+ export function recvTerminalResponse(session_id, timeout_ms = 30000) {
113
+ const session = terminalSessions.get(session_id);
114
+ if (!session) return { error: 'not_found', message: `Session ${session_id} not found` };
115
+
116
+ // If already ready with a response, return it immediately
117
+ if (session.status === 'ready' && session.lastResponse !== undefined) {
118
+ return {
119
+ session_id,
120
+ status: 'ready',
121
+ turn: session.turn || 0,
122
+ response: session.lastResponse,
123
+ responded_at: session.lastResponseAt,
124
+ };
125
+ }
126
+
127
+ // Session is still busy — caller should poll again
128
+ return {
129
+ session_id,
130
+ status: session.status,
131
+ turn: session.turn || 0,
132
+ response: null,
133
+ message: session.status === 'busy' ? 'Still processing — poll again in a few seconds' : 'No response yet',
134
+ };
135
+ }
@@ -0,0 +1,9 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { recvTerminalResponse } from "./terminal-dispatch.js";
5
+
6
+ test("recvTerminalResponse reports not_found for an unknown session", () => {
7
+ const result = recvTerminalResponse("does-not-exist");
8
+ assert.equal(result.error, "not_found");
9
+ });
@@ -0,0 +1,39 @@
1
+ // cli/services/terminal-registry.js
2
+ // The single shared in-memory registry every session kind (plain terminal,
3
+ // chitchat, codevelop) is stored in, plus the small stateless helpers that
4
+ // don't belong to any one session kind. Split out of terminal-sessions.js --
5
+ // the SOLID scorer flagged that file's original shape (72.1 total) as mixing
6
+ // this shared-state concern with dispatch logic and Windows-window control.
7
+ //
8
+ // mcpHttpBaseUrl is exported as a live "let" binding + a setter: createServer()
9
+ // sets it once the real listening port is known; ES modules do not allow an
10
+ // IMPORTING module to reassign an imported binding directly, so the setter is
11
+ // how serve.js updates it without a circular re-export.
12
+
13
+ import crypto from "node:crypto";
14
+ import path from "node:path";
15
+ import os from "node:os";
16
+ import fs from "node:fs";
17
+
18
+ export const terminalSessions = new Map(); // session_id → SessionState
19
+ export let mcpHttpBaseUrl = "http://127.0.0.1:52437";
20
+ export function setMcpHttpBaseUrl(url) { mcpHttpBaseUrl = url; }
21
+
22
+ // ── Channel webhook event queue ───────────────────────────────────────────────
23
+ export const channelEvents = []; // { id, event, resource, status, url, repository, created_at }
24
+ export const MAX_CHANNEL_EVENTS = 100;
25
+
26
+ export function generateSessionId() {
27
+ return crypto.randomUUID();
28
+ }
29
+
30
+ export function defaultTerminalCwd() {
31
+ const dir = process.env.CLAUTH_TERMINAL_CWD || path.join(os.tmpdir(), "clauth-terminal");
32
+ try {
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ } catch {
35
+ // If the neutral directory cannot be created, fall back to the user's home.
36
+ return os.homedir();
37
+ }
38
+ return dir;
39
+ }
@@ -0,0 +1,31 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ mcpHttpBaseUrl,
6
+ setMcpHttpBaseUrl,
7
+ channelEvents,
8
+ MAX_CHANNEL_EVENTS,
9
+ generateSessionId,
10
+ } from "./terminal-registry.js";
11
+
12
+ test("generateSessionId returns distinct UUIDs", () => {
13
+ const a = generateSessionId();
14
+ const b = generateSessionId();
15
+ assert.notEqual(a, b);
16
+ assert.match(a, /^[0-9a-f-]{36}$/);
17
+ });
18
+
19
+ test("setMcpHttpBaseUrl updates the live exported binding", () => {
20
+ const before = mcpHttpBaseUrl;
21
+ setMcpHttpBaseUrl("http://127.0.0.1:9999");
22
+ // Re-import isn't needed -- `mcpHttpBaseUrl` is a live ES module binding,
23
+ // so reading the SAME imported name again reflects the setter's update.
24
+ assert.equal(mcpHttpBaseUrl, "http://127.0.0.1:9999");
25
+ setMcpHttpBaseUrl(before); // restore, since this is shared module state
26
+ });
27
+
28
+ test("channelEvents starts as an array and MAX_CHANNEL_EVENTS caps it at a sane size", () => {
29
+ assert.ok(Array.isArray(channelEvents));
30
+ assert.equal(MAX_CHANNEL_EVENTS, 100);
31
+ });
@@ -0,0 +1,100 @@
1
+ // cli/services/terminal-sessions.js
2
+ // Named persistent Claude terminal session lifecycle (start/list/stop).
3
+ // Message dispatch moved to terminal-dispatch.js, Windows-window control
4
+ // moved to terminal-window.js, and the shared registry (terminalSessions Map,
5
+ // mcpHttpBaseUrl, channelEvents, generateSessionId, defaultTerminalCwd) moved
6
+ // to terminal-registry.js after the SOLID scorer flagged this file's original
7
+ // single-file shape (72.1 total) as mixing all four concerns.
8
+ //
9
+ // Sixth slice of the authorized serve.js module split.
10
+
11
+ import { getAgentPool } from "./agent-pool-instance.js";
12
+ import { findClaudeBinary } from "./claude-binary.js";
13
+ import { terminalSessions, generateSessionId, defaultTerminalCwd } from "./terminal-registry.js";
14
+
15
+ // ── Terminal session manager ─────────────────────────────────────
16
+ // Rolling-context approach: each session stores a context string.
17
+ // /terminal/send spawns a fresh claude -p with [context + message],
18
+ // captures stdout, stores result back in session context.
19
+
20
+ export function startTerminalSession(name, knowledge_tier, context_md, cwd, use_warm = false) {
21
+ const binary = findClaudeBinary();
22
+ if (!binary) {
23
+ return { error: 'binary_not_found', message: 'claude CLI not found in PATH or AppData/npm' };
24
+ }
25
+ const session_id = generateSessionId();
26
+ const preamble = context_md
27
+ ? `${context_md}\n\n---\n`
28
+ : '';
29
+ const initialContext = `${preamble}Session: ${name}, Tier: ${knowledge_tier}\nConversation transcript follows. Respond only to the final User message.`;
30
+ const session = {
31
+ session_id,
32
+ name,
33
+ knowledge_tier,
34
+ status: 'ready',
35
+ started_at: new Date().toISOString(),
36
+ context: initialContext,
37
+ activeProc: null,
38
+ warmWorker: null,
39
+ use_warm,
40
+ cwd: cwd || defaultTerminalCwd(),
41
+ };
42
+
43
+ // Try to acquire a warm worker only when explicitly requested. The warm pool
44
+ // is optimized for call_agent one-shots; a terminal session needs reliable
45
+ // repeated turns more than experimental low-latency pinning.
46
+ if (use_warm) {
47
+ try {
48
+ const pool = getAgentPool();
49
+ if (pool) {
50
+ const worker = pool.acquireForSession(session_id);
51
+ if (worker) {
52
+ session.warmWorker = worker;
53
+ console.log(`[terminal] session ${session_id} acquired warm worker`);
54
+ }
55
+ }
56
+ } catch (e) {
57
+ console.log(`[terminal] session ${session_id} warm pool unavailable, will use cold path: ${e.message}`);
58
+ }
59
+ }
60
+
61
+ terminalSessions.set(session_id, session);
62
+ console.log(`[terminal] started session ${session_id} name=${name} cwd=${session.cwd} warm=${!!session.warmWorker}`);
63
+ return { session_id, status: 'ready', warm: !!session.warmWorker };
64
+ }
65
+
66
+ export function listTerminalSessions() {
67
+ const sessions = [];
68
+ for (const [, s] of terminalSessions) {
69
+ sessions.push({
70
+ session_id: s.session_id,
71
+ name: s.name,
72
+ status: s.status,
73
+ knowledge_tier: s.knowledge_tier,
74
+ started_at: s.started_at,
75
+ });
76
+ }
77
+ return sessions;
78
+ }
79
+
80
+ export function stopTerminalSession(session_id) {
81
+ const session = terminalSessions.get(session_id);
82
+ if (!session) return { error: 'not_found', message: `Session ${session_id} not found` };
83
+ if (session.activeProc) {
84
+ try { session.activeProc.kill(); } catch {}
85
+ }
86
+ // Release the warm worker back to the pool
87
+ if (session.warmWorker) {
88
+ try {
89
+ const pool = getAgentPool();
90
+ pool.releaseSession(session_id);
91
+ console.log(`[terminal] session ${session_id} released warm worker`);
92
+ } catch (e) {
93
+ console.log(`[terminal] session ${session_id} warm worker release failed: ${e.message}`);
94
+ }
95
+ session.warmWorker = null;
96
+ }
97
+ terminalSessions.delete(session_id);
98
+ console.log(`[terminal] stopped session ${session_id}`);
99
+ return { stopped: true, session_id };
100
+ }
@@ -0,0 +1,26 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { terminalSessions } from "./terminal-registry.js";
5
+ import { listTerminalSessions, stopTerminalSession } from "./terminal-sessions.js";
6
+
7
+ test("listTerminalSessions reflects the shared terminalSessions Map directly", () => {
8
+ terminalSessions.clear();
9
+ terminalSessions.set("t1", { session_id: "t1", name: "test", status: "ready", knowledge_tier: "db_only", started_at: "2026-01-01T00:00:00Z" });
10
+ const list = listTerminalSessions();
11
+ assert.equal(list.length, 1);
12
+ assert.equal(list[0].session_id, "t1");
13
+ terminalSessions.clear();
14
+ });
15
+
16
+ test("stopTerminalSession reports not_found for an unknown session, without throwing", () => {
17
+ const result = stopTerminalSession("does-not-exist");
18
+ assert.equal(result.error, "not_found");
19
+ });
20
+
21
+ test("stopTerminalSession removes a real (non-process-backed) session from the shared Map", () => {
22
+ terminalSessions.set("t2", { session_id: "t2", name: "test", status: "ready", activeProc: null, warmWorker: null });
23
+ const result = stopTerminalSession("t2");
24
+ assert.equal(result.stopped, true);
25
+ assert.equal(terminalSessions.has("t2"), false);
26
+ });