@lifeaitools/clauth 1.18.1 → 1.19.2
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/cli/commands/serve.js +290 -32
- package/cli/studio-debug.js +60 -3
- package/package.json +1 -1
package/cli/commands/serve.js
CHANGED
|
@@ -3337,6 +3337,80 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3337
3337
|
});
|
|
3338
3338
|
}
|
|
3339
3339
|
|
|
3340
|
+
// Self-heal: mint a connector run-token from the Cloudflare API using the
|
|
3341
|
+
// vault's `cloudflare` token, so `cloudflared tunnel run --token` can start
|
|
3342
|
+
// the tunnel with ZERO local files (~/.cloudflared/config.yml + creds JSON).
|
|
3343
|
+
// This survives a home-dir wipe — clauth has the keys, so it rebuilds the
|
|
3344
|
+
// connector itself. Returns null on any failure so startTunnel() can fall
|
|
3345
|
+
// back to the legacy file-based `tunnel run`.
|
|
3346
|
+
async function getTunnelRunToken() {
|
|
3347
|
+
try {
|
|
3348
|
+
if (!password) return null;
|
|
3349
|
+
const sbUrl = (api.getBaseUrl() || "").replace("/functions/v1/auth-vault", "");
|
|
3350
|
+
const sbKey = api.getAnonKey();
|
|
3351
|
+
|
|
3352
|
+
// Resolve tunnelId: clauth_config.tunnel_id, else config.yml `tunnel:` line
|
|
3353
|
+
let tid = null;
|
|
3354
|
+
if (sbUrl && sbKey) {
|
|
3355
|
+
try {
|
|
3356
|
+
const r = await fetch(`${sbUrl}/rest/v1/clauth_config?key=eq.tunnel_id&select=value`,
|
|
3357
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) });
|
|
3358
|
+
if (r.ok) {
|
|
3359
|
+
const rows = await r.json();
|
|
3360
|
+
if (rows.length && rows[0].value && rows[0].value !== "null") {
|
|
3361
|
+
tid = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
} catch {}
|
|
3365
|
+
}
|
|
3366
|
+
if (!tid) {
|
|
3367
|
+
try {
|
|
3368
|
+
const yml = fs.readFileSync(path.join(os.homedir(), ".cloudflared", "config.yml"), "utf8");
|
|
3369
|
+
const m = yml.match(/^\s*tunnel:\s*(\S+)/m);
|
|
3370
|
+
if (m) tid = m[1];
|
|
3371
|
+
} catch {}
|
|
3372
|
+
}
|
|
3373
|
+
if (!tid) return null;
|
|
3374
|
+
|
|
3375
|
+
// CF API token from the vault
|
|
3376
|
+
const { token, timestamp } = deriveToken(password, machineHash);
|
|
3377
|
+
const cr = await api.retrieve(password, machineHash, token, timestamp, "cloudflare");
|
|
3378
|
+
const cfToken = cr?.value;
|
|
3379
|
+
if (!cfToken) return null;
|
|
3380
|
+
|
|
3381
|
+
// accountId: clauth_config.cf_account_id, else first account on the token
|
|
3382
|
+
let accountId = null;
|
|
3383
|
+
if (sbUrl && sbKey) {
|
|
3384
|
+
try {
|
|
3385
|
+
const r = await fetch(`${sbUrl}/rest/v1/clauth_config?key=eq.cf_account_id&select=value`,
|
|
3386
|
+
{ headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}` }, signal: AbortSignal.timeout(5000) });
|
|
3387
|
+
if (r.ok) {
|
|
3388
|
+
const rows = await r.json();
|
|
3389
|
+
if (rows.length && rows[0].value && rows[0].value !== "null") {
|
|
3390
|
+
accountId = typeof rows[0].value === "string" ? JSON.parse(rows[0].value) : rows[0].value;
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
} catch {}
|
|
3394
|
+
}
|
|
3395
|
+
if (!accountId) {
|
|
3396
|
+
const ar = await fetch("https://api.cloudflare.com/client/v4/accounts",
|
|
3397
|
+
{ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) });
|
|
3398
|
+
const ad = await ar.json();
|
|
3399
|
+
accountId = ad?.result?.[0]?.id || null;
|
|
3400
|
+
}
|
|
3401
|
+
if (!accountId) return null;
|
|
3402
|
+
|
|
3403
|
+
// Mint the connector run-token
|
|
3404
|
+
const tr = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel/${tid}/token`,
|
|
3405
|
+
{ headers: { Authorization: `Bearer ${cfToken}` }, signal: AbortSignal.timeout(8000) });
|
|
3406
|
+
const td = await tr.json();
|
|
3407
|
+
if (td?.success && td.result) return td.result;
|
|
3408
|
+
return null;
|
|
3409
|
+
} catch {
|
|
3410
|
+
return null;
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3340
3414
|
async function startTunnel() {
|
|
3341
3415
|
if (tunnelProc) {
|
|
3342
3416
|
// Already running — ensure status reflects reality (caller may have reset it to "starting")
|
|
@@ -3387,9 +3461,15 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3387
3461
|
// Named tunnel (fixed subdomain) or quick tunnel (random URL)
|
|
3388
3462
|
let args;
|
|
3389
3463
|
if (tunnelHostname) {
|
|
3390
|
-
//
|
|
3391
|
-
//
|
|
3392
|
-
|
|
3464
|
+
// Self-heal first: mint a connector token from the CF API (clauth has
|
|
3465
|
+
// the keys) and run `--token` — no local config.yml/creds needed.
|
|
3466
|
+
// Fall back to the legacy file-based `tunnel run` only if minting fails.
|
|
3467
|
+
const runToken = await getTunnelRunToken();
|
|
3468
|
+
if (runToken) {
|
|
3469
|
+
args = ["tunnel", "run", "--token", runToken];
|
|
3470
|
+
} else {
|
|
3471
|
+
args = ["tunnel", "run"];
|
|
3472
|
+
}
|
|
3393
3473
|
tunnelUrl = `https://${tunnelHostname}`;
|
|
3394
3474
|
} else {
|
|
3395
3475
|
// Quick tunnel — random URL each session
|
|
@@ -3777,6 +3857,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3777
3857
|
const NOAUTH_HOSTS = ["fs.regendevcorp.com", "clauth.regendevcorp.com", "chitchat.regendevcorp.com"];
|
|
3778
3858
|
const requestHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
3779
3859
|
const noAuthHost = NOAUTH_HOSTS.includes(requestHost);
|
|
3860
|
+
const localTrustedHost = ["127.0.0.1", "localhost", "::1", "[::1]"].includes(requestHost);
|
|
3780
3861
|
|
|
3781
3862
|
// ── OAuth Discovery (RFC 9728 + RFC 8414) ──────────────
|
|
3782
3863
|
// Suppress OAuth discovery on noauth hosts — prevents claude.ai from entering OAuth flow
|
|
@@ -3995,7 +4076,7 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
3995
4076
|
const authHeader = req.headers.authorization;
|
|
3996
4077
|
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
3997
4078
|
|
|
3998
|
-
if (!noAuthHost && (!token || !oauthTokens.has(token))) {
|
|
4079
|
+
if (!noAuthHost && !localTrustedHost && (!token || !oauthTokens.has(token))) {
|
|
3999
4080
|
// No valid Bearer token → return 401 with discovery hint
|
|
4000
4081
|
const base = oauthBase();
|
|
4001
4082
|
const resourcePath = isMcpPath ? reqPath.slice(1) : "sse"; // "mcp", "gws", "clauth", or "sse"
|
|
@@ -4012,8 +4093,8 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
4012
4093
|
}));
|
|
4013
4094
|
}
|
|
4014
4095
|
|
|
4015
|
-
// Valid token — mark
|
|
4016
|
-
req._clauthRemote =
|
|
4096
|
+
// Valid token or trusted localhost — mark remote status and fall through to MCP handling
|
|
4097
|
+
req._clauthRemote = !localTrustedHost;
|
|
4017
4098
|
}
|
|
4018
4099
|
|
|
4019
4100
|
// ── MCP Streamable HTTP transport ──
|
|
@@ -5257,6 +5338,29 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5257
5338
|
return;
|
|
5258
5339
|
}
|
|
5259
5340
|
|
|
5341
|
+
// POST /terminal/show — reveal/focus an active terminal session or open a visible inspector
|
|
5342
|
+
if (method === "POST" && reqPath === "/terminal/show") {
|
|
5343
|
+
let body = "";
|
|
5344
|
+
req.on("data", d => body += d);
|
|
5345
|
+
req.on("end", () => {
|
|
5346
|
+
try {
|
|
5347
|
+
const { session_id } = JSON.parse(body);
|
|
5348
|
+
if (!session_id) {
|
|
5349
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5350
|
+
return res.end(JSON.stringify({ error: "session_id required" }));
|
|
5351
|
+
}
|
|
5352
|
+
const result = showTerminalSession(session_id);
|
|
5353
|
+
const status = result.error === 'not_found' ? 404 : result.error ? 500 : 200;
|
|
5354
|
+
res.writeHead(status, { "Content-Type": "application/json", ...CORS });
|
|
5355
|
+
res.end(JSON.stringify(result));
|
|
5356
|
+
} catch {
|
|
5357
|
+
res.writeHead(400, { "Content-Type": "application/json", ...CORS });
|
|
5358
|
+
res.end(JSON.stringify({ error: "invalid JSON" }));
|
|
5359
|
+
}
|
|
5360
|
+
});
|
|
5361
|
+
return;
|
|
5362
|
+
}
|
|
5363
|
+
|
|
5260
5364
|
// POST /terminal/stop — stop a terminal session
|
|
5261
5365
|
if (method === "POST" && reqPath === "/terminal/stop") {
|
|
5262
5366
|
let body = "";
|
|
@@ -6220,6 +6324,16 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
6220
6324
|
body: JSON.stringify({ key: "tunnel_hostname", value: JSON.stringify(hostname) }),
|
|
6221
6325
|
});
|
|
6222
6326
|
|
|
6327
|
+
// Persist tunnel_id too — required for token-based self-heal after a
|
|
6328
|
+
// daemon restart or a ~/.cloudflared wipe (getTunnelRunToken reads it).
|
|
6329
|
+
if (tunnelId) {
|
|
6330
|
+
await fetch(`${sbUrl}/rest/v1/clauth_config`, {
|
|
6331
|
+
method: "POST",
|
|
6332
|
+
headers: { apikey: sbKey, Authorization: `Bearer ${sbKey}`, "Content-Type": "application/json", Prefer: "resolution=merge-duplicates" },
|
|
6333
|
+
body: JSON.stringify({ key: "tunnel_id", value: JSON.stringify(tunnelId) }),
|
|
6334
|
+
});
|
|
6335
|
+
}
|
|
6336
|
+
|
|
6223
6337
|
tunnelHostname = hostname;
|
|
6224
6338
|
tunnelUrl = `https://${hostname}`;
|
|
6225
6339
|
|
|
@@ -7899,7 +8013,18 @@ function generateSessionId() {
|
|
|
7899
8013
|
return crypto.randomUUID();
|
|
7900
8014
|
}
|
|
7901
8015
|
|
|
7902
|
-
function
|
|
8016
|
+
function defaultTerminalCwd() {
|
|
8017
|
+
const dir = process.env.CLAUTH_TERMINAL_CWD || path.join(os.tmpdir(), "clauth-tintin-terminal");
|
|
8018
|
+
try {
|
|
8019
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
8020
|
+
} catch {
|
|
8021
|
+
// If the neutral directory cannot be created, fall back to the user's home.
|
|
8022
|
+
return os.homedir();
|
|
8023
|
+
}
|
|
8024
|
+
return dir;
|
|
8025
|
+
}
|
|
8026
|
+
|
|
8027
|
+
function startTerminalSession(name, knowledge_tier, context_md, cwd, use_warm = false) {
|
|
7903
8028
|
const binary = findClaudeBinary();
|
|
7904
8029
|
if (!binary) {
|
|
7905
8030
|
return { error: 'binary_not_found', message: 'claude CLI not found in PATH or AppData/npm' };
|
|
@@ -7908,7 +8033,7 @@ function startTerminalSession(name, knowledge_tier, context_md, cwd) {
|
|
|
7908
8033
|
const preamble = context_md
|
|
7909
8034
|
? `${context_md}\n\n---\n`
|
|
7910
8035
|
: '';
|
|
7911
|
-
const initialContext = `${preamble}Session: ${name}, Tier: ${knowledge_tier}\
|
|
8036
|
+
const initialContext = `${preamble}Session: ${name}, Tier: ${knowledge_tier}\nConversation transcript follows. Respond only to the final User message.`;
|
|
7912
8037
|
const session = {
|
|
7913
8038
|
session_id,
|
|
7914
8039
|
name,
|
|
@@ -7918,26 +8043,31 @@ function startTerminalSession(name, knowledge_tier, context_md, cwd) {
|
|
|
7918
8043
|
context: initialContext,
|
|
7919
8044
|
activeProc: null,
|
|
7920
8045
|
warmWorker: null,
|
|
7921
|
-
|
|
8046
|
+
use_warm,
|
|
8047
|
+
cwd: cwd || defaultTerminalCwd(),
|
|
7922
8048
|
};
|
|
7923
8049
|
|
|
7924
|
-
// Try to acquire a warm worker
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
8050
|
+
// Try to acquire a warm worker only when explicitly requested. The warm pool
|
|
8051
|
+
// is optimized for call_agent one-shots; TinTin browser chat needs reliable
|
|
8052
|
+
// repeated turns more than experimental low-latency pinning.
|
|
8053
|
+
if (use_warm) {
|
|
8054
|
+
try {
|
|
8055
|
+
const pool = getAgentPool();
|
|
8056
|
+
if (pool) {
|
|
8057
|
+
const worker = pool.acquireForSession(session_id);
|
|
8058
|
+
if (worker) {
|
|
8059
|
+
session.warmWorker = worker;
|
|
8060
|
+
console.log(`[terminal] session ${session_id} acquired warm worker`);
|
|
8061
|
+
}
|
|
7932
8062
|
}
|
|
8063
|
+
} catch (e) {
|
|
8064
|
+
console.log(`[terminal] session ${session_id} warm pool unavailable, will use cold path: ${e.message}`);
|
|
7933
8065
|
}
|
|
7934
|
-
} catch (e) {
|
|
7935
|
-
console.log(`[terminal] session ${session_id} warm pool unavailable, will use cold path: ${e.message}`);
|
|
7936
8066
|
}
|
|
7937
8067
|
|
|
7938
8068
|
terminalSessions.set(session_id, session);
|
|
7939
8069
|
console.log(`[terminal] started session ${session_id} name=${name} cwd=${session.cwd} warm=${!!session.warmWorker}`);
|
|
7940
|
-
return { session_id, status: 'ready' };
|
|
8070
|
+
return { session_id, status: 'ready', warm: !!session.warmWorker };
|
|
7941
8071
|
}
|
|
7942
8072
|
|
|
7943
8073
|
function sendTerminalMessage(session_id, message) {
|
|
@@ -7946,6 +8076,11 @@ function sendTerminalMessage(session_id, message) {
|
|
|
7946
8076
|
if (session.status === 'stopped') return { error: 'stopped', message: 'Session is stopped' };
|
|
7947
8077
|
if (session.status === 'busy') return { error: 'session_busy', message: 'Session is busy — try again shortly' };
|
|
7948
8078
|
|
|
8079
|
+
const nextTurn = (session.turn || 0) + 1;
|
|
8080
|
+
session.lastResponse = undefined;
|
|
8081
|
+
session.lastResponseAt = null;
|
|
8082
|
+
session.pendingTurn = nextTurn;
|
|
8083
|
+
|
|
7949
8084
|
// ── Warm-pool path: dispatch to the pinned worker (maintains its own context) ──
|
|
7950
8085
|
if (session.warmWorker && !session.warmWorker.isDead()) {
|
|
7951
8086
|
session.status = 'busy';
|
|
@@ -7963,7 +8098,8 @@ function sendTerminalMessage(session_id, message) {
|
|
|
7963
8098
|
s.context = combined.length > 8000 ? combined.slice(combined.length - 8000) : combined;
|
|
7964
8099
|
s.lastResponse = response;
|
|
7965
8100
|
s.lastResponseAt = new Date().toISOString();
|
|
7966
|
-
s.turn = (s.turn || 0) + 1;
|
|
8101
|
+
s.turn = s.pendingTurn || ((s.turn || 0) + 1);
|
|
8102
|
+
s.pendingTurn = null;
|
|
7967
8103
|
s.status = 'ready';
|
|
7968
8104
|
s.activeProc = null;
|
|
7969
8105
|
console.log(`[terminal] session ${session_id} turn=${s.turn} complete (warm) ok=${result.ok}`);
|
|
@@ -7979,13 +8115,14 @@ function sendTerminalMessage(session_id, message) {
|
|
|
7979
8115
|
const s = terminalSessions.get(session_id);
|
|
7980
8116
|
s.lastResponse = `(warm dispatch failed: ${err.message})`;
|
|
7981
8117
|
s.lastResponseAt = new Date().toISOString();
|
|
7982
|
-
s.turn = (s.turn || 0) + 1;
|
|
8118
|
+
s.turn = s.pendingTurn || ((s.turn || 0) + 1);
|
|
8119
|
+
s.pendingTurn = null;
|
|
7983
8120
|
s.status = 'ready';
|
|
7984
8121
|
s.activeProc = null;
|
|
7985
8122
|
s.warmWorker = null; // fall back to cold on next turn
|
|
7986
8123
|
console.log(`[terminal] session ${session_id} warm dispatch threw, falling back to cold: ${err.message}`);
|
|
7987
8124
|
});
|
|
7988
|
-
return { queued: true, session_id, warm: true };
|
|
8125
|
+
return { queued: true, session_id, warm: true, turn: nextTurn };
|
|
7989
8126
|
}
|
|
7990
8127
|
|
|
7991
8128
|
// ── Cold-spawn fallback: original path ──
|
|
@@ -7993,13 +8130,16 @@ function sendTerminalMessage(session_id, message) {
|
|
|
7993
8130
|
if (!binary) return { error: 'binary_not_found', message: 'claude CLI not found in PATH or AppData/npm' };
|
|
7994
8131
|
|
|
7995
8132
|
session.status = 'busy';
|
|
7996
|
-
const
|
|
7997
|
-
|
|
7998
|
-
const
|
|
8133
|
+
const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(binary);
|
|
8134
|
+
const command = isCmdShim ? "cmd" : binary;
|
|
8135
|
+
const args = isCmdShim
|
|
8136
|
+
? ["/d", "/s", "/c", `"${binary}"`, "-p", message, "--system-prompt", session.context, "--setting-sources", "user", "--dangerously-skip-permissions"]
|
|
8137
|
+
: ["-p", message, "--system-prompt", session.context, "--setting-sources", "user", "--dangerously-skip-permissions"];
|
|
8138
|
+
const proc = spawnProc(command, args, {
|
|
7999
8139
|
cwd: session.cwd || CHITCHAT_FALLBACK_CWD,
|
|
8000
8140
|
env: process.env,
|
|
8001
8141
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8002
|
-
shell:
|
|
8142
|
+
shell: false,
|
|
8003
8143
|
windowsHide: true,
|
|
8004
8144
|
});
|
|
8005
8145
|
session.activeProc = proc;
|
|
@@ -8018,14 +8158,15 @@ function sendTerminalMessage(session_id, message) {
|
|
|
8018
8158
|
s.context = combined.length > 8000 ? combined.slice(combined.length - 8000) : combined;
|
|
8019
8159
|
s.lastResponse = response;
|
|
8020
8160
|
s.lastResponseAt = new Date().toISOString();
|
|
8021
|
-
s.turn = (s.turn || 0) + 1;
|
|
8161
|
+
s.turn = s.pendingTurn || ((s.turn || 0) + 1);
|
|
8162
|
+
s.pendingTurn = null;
|
|
8022
8163
|
s.status = 'ready';
|
|
8023
8164
|
s.activeProc = null;
|
|
8024
8165
|
console.log(`[terminal] session ${session_id} turn=${s.turn} complete code=${code}`);
|
|
8025
8166
|
}
|
|
8026
8167
|
});
|
|
8027
8168
|
|
|
8028
|
-
return { queued: true, session_id, warm: false };
|
|
8169
|
+
return { queued: true, session_id, warm: false, turn: nextTurn };
|
|
8029
8170
|
}
|
|
8030
8171
|
|
|
8031
8172
|
function recvTerminalResponse(session_id, timeout_ms = 30000) {
|
|
@@ -8067,6 +8208,101 @@ function listTerminalSessions() {
|
|
|
8067
8208
|
return sessions;
|
|
8068
8209
|
}
|
|
8069
8210
|
|
|
8211
|
+
function showTerminalSession(session_id) {
|
|
8212
|
+
const session = terminalSessions.get(session_id);
|
|
8213
|
+
if (!session) return { error: 'not_found', message: `Session ${session_id} not found` };
|
|
8214
|
+
|
|
8215
|
+
const pid = session.activeProc?.pid || null;
|
|
8216
|
+
if (process.platform === "win32" && pid) {
|
|
8217
|
+
try {
|
|
8218
|
+
const script = [
|
|
8219
|
+
"Add-Type @'",
|
|
8220
|
+
"using System;",
|
|
8221
|
+
"using System.Runtime.InteropServices;",
|
|
8222
|
+
"public class Win32ShowWindow {",
|
|
8223
|
+
" [DllImport(\"user32.dll\")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);",
|
|
8224
|
+
" [DllImport(\"user32.dll\")] public static extern bool SetForegroundWindow(IntPtr hWnd);",
|
|
8225
|
+
"}",
|
|
8226
|
+
"'@",
|
|
8227
|
+
`$p = Get-Process -Id ${Number(pid)} -ErrorAction SilentlyContinue`,
|
|
8228
|
+
"if ($p -and $p.MainWindowHandle -ne 0) {",
|
|
8229
|
+
" [Win32ShowWindow]::ShowWindowAsync($p.MainWindowHandle, 5) | Out-Null",
|
|
8230
|
+
" [Win32ShowWindow]::SetForegroundWindow($p.MainWindowHandle) | Out-Null",
|
|
8231
|
+
" 'shown'",
|
|
8232
|
+
"} else {",
|
|
8233
|
+
" 'no-window'",
|
|
8234
|
+
"}",
|
|
8235
|
+
].join("\n");
|
|
8236
|
+
const result = spawnSync("powershell.exe", [
|
|
8237
|
+
"-NoProfile",
|
|
8238
|
+
"-ExecutionPolicy",
|
|
8239
|
+
"Bypass",
|
|
8240
|
+
"-Command",
|
|
8241
|
+
script,
|
|
8242
|
+
], {
|
|
8243
|
+
encoding: "utf8",
|
|
8244
|
+
windowsHide: true,
|
|
8245
|
+
timeout: 5000,
|
|
8246
|
+
});
|
|
8247
|
+
const stdout = String(result.stdout || "").trim();
|
|
8248
|
+
if (stdout.includes("shown")) {
|
|
8249
|
+
session.lastShownAt = new Date().toISOString();
|
|
8250
|
+
return { shown: true, mode: "active_process", session_id, pid };
|
|
8251
|
+
}
|
|
8252
|
+
} catch (e) {
|
|
8253
|
+
console.log(`[terminal] session ${session_id} active process show failed: ${e.message}`);
|
|
8254
|
+
}
|
|
8255
|
+
}
|
|
8256
|
+
|
|
8257
|
+
if (process.platform === "win32") {
|
|
8258
|
+
try {
|
|
8259
|
+
const title = `TinTin ${session.name || session_id}`;
|
|
8260
|
+
const lines = [
|
|
8261
|
+
`$Host.UI.RawUI.WindowTitle = ${JSON.stringify(title)}`,
|
|
8262
|
+
`Set-Location -LiteralPath ${JSON.stringify(session.cwd || os.homedir())}`,
|
|
8263
|
+
"Clear-Host",
|
|
8264
|
+
`Write-Host ${JSON.stringify(`TinTin session ${session_id}`)} -ForegroundColor Cyan`,
|
|
8265
|
+
`Write-Host ${JSON.stringify(`Name: ${session.name || ""}`)}`,
|
|
8266
|
+
`Write-Host ${JSON.stringify(`Status: ${session.status}`)}`,
|
|
8267
|
+
`Write-Host ${JSON.stringify(`CWD: ${session.cwd || ""}`)}`,
|
|
8268
|
+
`Write-Host ${JSON.stringify(`Turn: ${session.turn || 0}`)}`,
|
|
8269
|
+
`Write-Host ${JSON.stringify("This viewer is attached to clauth session metadata. If a Claude turn is active, terminal_show focuses it; otherwise this is the visible session inspection point.")} -ForegroundColor Yellow`,
|
|
8270
|
+
"Write-Host ''",
|
|
8271
|
+
"Write-Host 'Recent response preview:' -ForegroundColor Green",
|
|
8272
|
+
`Write-Host ${JSON.stringify((session.lastResponse || "").slice(0, 1500))}`,
|
|
8273
|
+
"Write-Host ''",
|
|
8274
|
+
"Write-Host 'Leave this window open or close it when done.' -ForegroundColor DarkGray",
|
|
8275
|
+
].join("; ");
|
|
8276
|
+
const child = spawnProc("powershell.exe", [
|
|
8277
|
+
"-NoExit",
|
|
8278
|
+
"-NoProfile",
|
|
8279
|
+
"-ExecutionPolicy",
|
|
8280
|
+
"Bypass",
|
|
8281
|
+
"-Command",
|
|
8282
|
+
lines,
|
|
8283
|
+
], {
|
|
8284
|
+
cwd: session.cwd || os.homedir(),
|
|
8285
|
+
env: process.env,
|
|
8286
|
+
stdio: "ignore",
|
|
8287
|
+
detached: true,
|
|
8288
|
+
windowsHide: false,
|
|
8289
|
+
});
|
|
8290
|
+
child.unref?.();
|
|
8291
|
+
session.lastShownAt = new Date().toISOString();
|
|
8292
|
+
return { shown: true, mode: "session_inspector", session_id, pid: child.pid || null };
|
|
8293
|
+
} catch (e) {
|
|
8294
|
+
return { error: "show_failed", message: e.message };
|
|
8295
|
+
}
|
|
8296
|
+
}
|
|
8297
|
+
|
|
8298
|
+
return {
|
|
8299
|
+
shown: false,
|
|
8300
|
+
mode: "unsupported_platform",
|
|
8301
|
+
session_id,
|
|
8302
|
+
message: "terminal_show currently opens/focuses local Windows sessions only.",
|
|
8303
|
+
};
|
|
8304
|
+
}
|
|
8305
|
+
|
|
8070
8306
|
function stopTerminalSession(session_id) {
|
|
8071
8307
|
const session = terminalSessions.get(session_id);
|
|
8072
8308
|
if (!session) return { error: 'not_found', message: `Session ${session_id} not found` };
|
|
@@ -9227,6 +9463,8 @@ const MCP_TOOLS = [
|
|
|
9227
9463
|
name: { type: "string", description: "Human-readable session name (e.g. 'knowledgedude-prt')" },
|
|
9228
9464
|
knowledge_tier: { type: "string", enum: ["db_only", "corpus", "project"], description: "Knowledge tier for the session. 'corpus' injects context_md as preamble." },
|
|
9229
9465
|
context_md: { type: "string", description: "Optional markdown preamble injected as session context (used for 'corpus' tier)" },
|
|
9466
|
+
cwd: { type: "string", description: "Optional working directory. Defaults to a neutral temp directory so browser chat avoids repo hooks." },
|
|
9467
|
+
use_warm: { type: "boolean", description: "Opt into experimental pinned warm-worker mode. Defaults to false for reliable browser chat turns." },
|
|
9230
9468
|
},
|
|
9231
9469
|
required: ["name"],
|
|
9232
9470
|
additionalProperties: false,
|
|
@@ -9250,6 +9488,18 @@ const MCP_TOOLS = [
|
|
|
9250
9488
|
description: "List all active terminal sessions with their status (ready/busy/stopped), knowledge tier, and start time.",
|
|
9251
9489
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
9252
9490
|
},
|
|
9491
|
+
{
|
|
9492
|
+
name: "terminal_show",
|
|
9493
|
+
description: "Reveal/focus a TinTin terminal session on this Windows machine. If no live Claude process window exists, opens a visible session inspector at the session cwd.",
|
|
9494
|
+
inputSchema: {
|
|
9495
|
+
type: "object",
|
|
9496
|
+
properties: {
|
|
9497
|
+
session_id: { type: "string", description: "Session ID returned by terminal_start" },
|
|
9498
|
+
},
|
|
9499
|
+
required: ["session_id"],
|
|
9500
|
+
additionalProperties: false,
|
|
9501
|
+
},
|
|
9502
|
+
},
|
|
9253
9503
|
{
|
|
9254
9504
|
name: "terminal_recv",
|
|
9255
9505
|
description: "Read the last response from a terminal session. Returns response if ready, or status='busy' if still processing. Poll every 3-5 seconds until status='ready'.",
|
|
@@ -10995,10 +11245,10 @@ async function handleMcpTool(vault, name, args) {
|
|
|
10995
11245
|
}
|
|
10996
11246
|
|
|
10997
11247
|
case "terminal_start": {
|
|
10998
|
-
const { name, knowledge_tier, context_md } = args;
|
|
11248
|
+
const { name, knowledge_tier, context_md, use_warm, cwd: requestedCwd } = args;
|
|
10999
11249
|
if (!name) return mcpError("name required");
|
|
11000
|
-
const cwd =
|
|
11001
|
-
const result = startTerminalSession(name, knowledge_tier || 'db_only', context_md || null, cwd);
|
|
11250
|
+
const cwd = requestedCwd ? path.resolve(String(requestedCwd)) : defaultTerminalCwd();
|
|
11251
|
+
const result = startTerminalSession(name, knowledge_tier || 'db_only', context_md || null, cwd, use_warm === true);
|
|
11002
11252
|
if (result.error) return mcpError(`${result.error}: ${result.message}`);
|
|
11003
11253
|
return mcpResult(JSON.stringify(result));
|
|
11004
11254
|
}
|
|
@@ -11015,6 +11265,14 @@ async function handleMcpTool(vault, name, args) {
|
|
|
11015
11265
|
return mcpResult(JSON.stringify(listTerminalSessions()));
|
|
11016
11266
|
}
|
|
11017
11267
|
|
|
11268
|
+
case "terminal_show": {
|
|
11269
|
+
const { session_id } = args;
|
|
11270
|
+
if (!session_id) return mcpError("session_id required");
|
|
11271
|
+
const result = showTerminalSession(session_id);
|
|
11272
|
+
if (result.error) return mcpError(`${result.error}: ${result.message}`);
|
|
11273
|
+
return mcpResult(JSON.stringify(result));
|
|
11274
|
+
}
|
|
11275
|
+
|
|
11018
11276
|
case "terminal_recv": {
|
|
11019
11277
|
const { session_id } = args;
|
|
11020
11278
|
if (!session_id) return mcpError("session_id required");
|
package/cli/studio-debug.js
CHANGED
|
@@ -9,6 +9,7 @@ const MAX_POLL_TIMEOUT = 600_000;
|
|
|
9
9
|
const MAX_EVENT_QUEUE = 100;
|
|
10
10
|
const MAX_SNIPPET = 2_000;
|
|
11
11
|
const STYLE_WRITE_EXTENSIONS = new Set([".css", ".scss", ".sass", ".less", ".html", ".jsx", ".tsx"]);
|
|
12
|
+
const TEXT_WRITE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".html", ".md", ".txt", ".css", ".scss"]);
|
|
12
13
|
|
|
13
14
|
const APP_TARGETS = {
|
|
14
15
|
studio_test: {
|
|
@@ -99,16 +100,34 @@ function writeJson(res, status, data, cors) {
|
|
|
99
100
|
res.end(JSON.stringify(data));
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
function resolveSourceFile(session, file) {
|
|
103
|
+
function resolveSourceFile(session, file, allowedExtensions = STYLE_WRITE_EXTENSIONS) {
|
|
103
104
|
if (!file || typeof file !== "string") return null;
|
|
104
105
|
const root = path.resolve(session.repoRoot || session.cwd || process.cwd());
|
|
105
106
|
const candidate = path.isAbsolute(file) ? path.resolve(file) : path.resolve(root, file);
|
|
106
107
|
const rel = path.relative(root, candidate);
|
|
107
108
|
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
108
|
-
if (!
|
|
109
|
+
if (!allowedExtensions.has(path.extname(candidate).toLowerCase())) return null;
|
|
109
110
|
return { root, path: candidate, relativePath: rel.replace(/\\/g, "/") };
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
function applyTextRangeEdit(sourceText, textEdit) {
|
|
114
|
+
const source = textEdit.source;
|
|
115
|
+
if (!source || !source.range || typeof source.range.start !== "number" || typeof source.range.end !== "number") {
|
|
116
|
+
throw new Error("text-write requires source.range.start and source.range.end.");
|
|
117
|
+
}
|
|
118
|
+
const { start, end } = source.range;
|
|
119
|
+
if (start < 0 || end < start || end > sourceText.length) {
|
|
120
|
+
throw new Error(`text-write source range [${start}, ${end}] is out of bounds (file length: ${sourceText.length}).`);
|
|
121
|
+
}
|
|
122
|
+
if (textEdit.oldText !== undefined) {
|
|
123
|
+
const oldSlice = sourceText.slice(start, end);
|
|
124
|
+
if (oldSlice !== textEdit.oldText) {
|
|
125
|
+
throw new Error(`text-write oldText mismatch at [${start}, ${end}]: expected "${String(textEdit.oldText).slice(0, 80)}", found "${oldSlice.slice(0, 80)}".`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return `${sourceText.slice(0, start)}${textEdit.newText}${sourceText.slice(end)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
112
131
|
function escapeRegExp(value) {
|
|
113
132
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
133
|
}
|
|
@@ -451,6 +470,40 @@ export class StudioDebugSessionStore {
|
|
|
451
470
|
return { ok: true, status: session.status, reply };
|
|
452
471
|
}
|
|
453
472
|
|
|
473
|
+
writeText(sessionId, body) {
|
|
474
|
+
const auth = this.authenticate(sessionId, body.token);
|
|
475
|
+
if (auth.error) return auth;
|
|
476
|
+
const session = auth.session;
|
|
477
|
+
const textEdit = body.textEdit && typeof body.textEdit === "object" ? body.textEdit : {};
|
|
478
|
+
const source = textEdit.source && typeof textEdit.source === "object" ? textEdit.source : {};
|
|
479
|
+
const file = source.file || textEdit.file || body.file;
|
|
480
|
+
const fileInfo = resolveSourceFile(session, file, TEXT_WRITE_EXTENSIONS);
|
|
481
|
+
if (!fileInfo) {
|
|
482
|
+
return { error: "invalid_source", message: `text-write requires a source file inside the debug session repo (got: ${file || "(none)"}).` };
|
|
483
|
+
}
|
|
484
|
+
if (typeof textEdit.newText !== "string") {
|
|
485
|
+
return { error: "invalid_body", message: "text-write requires textEdit.newText." };
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
const original = fs.readFileSync(fileInfo.path, "utf8");
|
|
489
|
+
const updated = applyTextRangeEdit(original, { ...textEdit, source });
|
|
490
|
+
fs.writeFileSync(fileInfo.path, updated, "utf8");
|
|
491
|
+
const reply = {
|
|
492
|
+
eventId: body.id || `text_${crypto.randomUUID()}`,
|
|
493
|
+
status: "done",
|
|
494
|
+
message: `Direct text write (source_replace [${source.range?.start}, ${source.range?.end}])`,
|
|
495
|
+
filesChanged: [fileInfo.relativePath],
|
|
496
|
+
createdAt: nowIso(),
|
|
497
|
+
};
|
|
498
|
+
session.replies.push(reply);
|
|
499
|
+
session.status = "done";
|
|
500
|
+
session.updatedAt = nowIso();
|
|
501
|
+
return { ok: true, status: "done", eventId: reply.eventId, filesChanged: reply.filesChanged };
|
|
502
|
+
} catch (err) {
|
|
503
|
+
return { error: "text_write_failed", message: err instanceof Error ? err.message : String(err) };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
454
507
|
writeStyle(sessionId, body) {
|
|
455
508
|
const auth = this.authenticate(sessionId, body.token);
|
|
456
509
|
if (auth.error) return auth;
|
|
@@ -551,7 +604,7 @@ export function createStudioDebugRuntime(options) {
|
|
|
551
604
|
}
|
|
552
605
|
}
|
|
553
606
|
|
|
554
|
-
const debugMatch = reqPath.match(/^\/studio\/debug\/([^/]+)\/(events|style-write|status|stop)$/);
|
|
607
|
+
const debugMatch = reqPath.match(/^\/studio\/debug\/([^/]+)\/(events|style-write|text-write|status|stop)$/);
|
|
555
608
|
const claudeMatch = reqPath.match(/^\/studio\/claude\/([^/]+)\/(poll|reply|status)$/);
|
|
556
609
|
if (!debugMatch && !claudeMatch) return false;
|
|
557
610
|
const [, sessionId, action] = debugMatch || claudeMatch;
|
|
@@ -565,6 +618,10 @@ export function createStudioDebugRuntime(options) {
|
|
|
565
618
|
const result = store.writeStyle(sessionId, await readBody(req));
|
|
566
619
|
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 400 : 200, result, cors);
|
|
567
620
|
}
|
|
621
|
+
if (method === "POST" && action === "text-write") {
|
|
622
|
+
const result = store.writeText(sessionId, await readBody(req));
|
|
623
|
+
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 400 : 200, result, cors);
|
|
624
|
+
}
|
|
568
625
|
if (method === "GET" && action === "poll") {
|
|
569
626
|
const result = await store.poll(sessionId, url.searchParams.get("token"), url.searchParams.get("timeout"));
|
|
570
627
|
return writeJson(res, result.error === "unauthorized" ? 401 : result.error ? 404 : 200, result, cors);
|