@stage-labs/metro 0.1.0-beta.101 → 0.1.0-beta.103
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/dist/claude.js +37 -11
- package/dist/mcp-config.js +25 -0
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +45 -6
- package/runtime/node_modules/@metro-labs/daemon/src/claude/onboarding.ts +23 -1
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +187 -0
- package/runtime/runtime.json +1 -1
package/dist/claude.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { markOnboardingDone } from './onboarding.js';
|
|
3
|
+
import { writeMcpConfig } from './mcp-config.js';
|
|
3
4
|
import { settingsConflicts, settingsFiles } from './claude-settings.js';
|
|
4
5
|
import { localAgents, pickLocalAgent } from './local.js';
|
|
5
6
|
import { PROVIDER_FLAGS } from './provider-flags.js';
|
|
@@ -7,8 +8,9 @@ import { localPort, localUrl } from './runtime.js';
|
|
|
7
8
|
const CHANNEL_FLAGS = ['--dangerously-load-development-channels', 'server:metro'];
|
|
8
9
|
const KEY_HEADER = 'x-metro-key';
|
|
9
10
|
const PROBE_MS = 3_000;
|
|
10
|
-
export const claudeArgs = (extra) => [
|
|
11
|
+
export const claudeArgs = (extra, mcpConfig) => [
|
|
11
12
|
...CHANNEL_FLAGS,
|
|
13
|
+
...(mcpConfig === undefined ? [] : ['--mcp-config', mcpConfig]),
|
|
12
14
|
...extra,
|
|
13
15
|
];
|
|
14
16
|
const set = (env, name) => (env[name] ?? '').trim() !== '';
|
|
@@ -114,19 +116,43 @@ export function runClaude(args, env) {
|
|
|
114
116
|
});
|
|
115
117
|
});
|
|
116
118
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
async function servedKey(decision) {
|
|
120
|
+
if ('key' in decision)
|
|
121
|
+
return decision.key;
|
|
122
|
+
const picked = agentKey(localAgentList(), process.env.METRO_AGENT);
|
|
123
|
+
if ('skip' in picked)
|
|
124
|
+
return null;
|
|
125
|
+
return (await daemonServing()) ? picked.key : null;
|
|
126
|
+
}
|
|
127
|
+
function mcpConfigFor(key, port) {
|
|
128
|
+
if (key === null)
|
|
129
|
+
return null;
|
|
130
|
+
const file = writeMcpConfig(key, port);
|
|
131
|
+
process.stderr.write(`metro claude: the metro MCP server is loaded for this session from the daemon at http://127.0.0.1:${String(port)}/mcp, nothing to add by hand\n`);
|
|
132
|
+
return file;
|
|
133
|
+
}
|
|
134
|
+
function gatewayLaunchEnv(key, port) {
|
|
124
135
|
process.stderr.write(`metro claude: inference goes through the daemon at http://127.0.0.1:${String(port)}/gateway (the Model page decides where)\n`);
|
|
125
|
-
const routed = gatewayEnv(process.env,
|
|
126
|
-
const env = credentialEnv(routed,
|
|
136
|
+
const routed = gatewayEnv(process.env, key, port);
|
|
137
|
+
const env = credentialEnv(routed, key, claudeSignedIn());
|
|
127
138
|
if (env !== routed)
|
|
128
139
|
process.stderr.write("metro claude: Claude Code has no login of its own here, so metro's key stands in as its credential; the Model page must route to Bedrock, OpenRouter or Codex\n");
|
|
129
140
|
if (markOnboardingDone() === 'marked')
|
|
130
141
|
process.stderr.write("metro claude: skipped Claude Code's first-run setup, since it already has a credential here\n");
|
|
131
|
-
return
|
|
142
|
+
return env;
|
|
143
|
+
}
|
|
144
|
+
export async function launchClaude(extra) {
|
|
145
|
+
const decision = await verdict();
|
|
146
|
+
const port = localPort();
|
|
147
|
+
const mcp = mcpConfigFor(await servedKey(decision), port);
|
|
148
|
+
try {
|
|
149
|
+
if ('skip' in decision) {
|
|
150
|
+
process.stderr.write(`metro claude: ${decision.skip}\n`);
|
|
151
|
+
return await runClaude(claudeArgs(extra, mcp?.path), process.env);
|
|
152
|
+
}
|
|
153
|
+
return await runClaude(claudeArgs(extra, mcp?.path), gatewayLaunchEnv(decision.key, port));
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
mcp?.cleanup();
|
|
157
|
+
}
|
|
132
158
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const METRO_SERVER = 'metro';
|
|
5
|
+
export function metroMcpConfig(agentKey, port) {
|
|
6
|
+
const servers = {
|
|
7
|
+
[METRO_SERVER]: {
|
|
8
|
+
type: 'http',
|
|
9
|
+
url: `http://127.0.0.1:${String(port)}/mcp`,
|
|
10
|
+
headers: { Authorization: `Bearer ${agentKey}` },
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
return `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`;
|
|
14
|
+
}
|
|
15
|
+
export function writeMcpConfig(agentKey, port, base = tmpdir()) {
|
|
16
|
+
const dir = mkdtempSync(join(base, 'metro-claude-'));
|
|
17
|
+
const path = join(dir, 'mcp.json');
|
|
18
|
+
writeFileSync(path, metroMcpConfig(agentKey, port), { mode: 0o600 });
|
|
19
|
+
return {
|
|
20
|
+
path,
|
|
21
|
+
cleanup: () => {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.103",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.103",
|
|
4
4
|
"description": "Metro connectors for Claude Code. Runs beside the metro daemon on this machine: each connector the agent holds is one MCP server relayed through the daemon, the daemon keeps that list current on its own, and /reload-plugins picks up a new one without leaving your session. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -23,6 +23,7 @@ import { agentsDir, fileSource } from '../agents/files.js';
|
|
|
23
23
|
import { ConnectorWatch } from '../connectors/watch.js';
|
|
24
24
|
import { syncPluginServers } from '../connectors/plugin-sync.js';
|
|
25
25
|
import { ensureMetroPlugin } from '../claude/plugin-install.js';
|
|
26
|
+
import { unwatchSession, watchSession } from '../claude/session.js';
|
|
26
27
|
import { applyLocalOwner } from './local-owner.js';
|
|
27
28
|
import { localOwner } from '../agents/file-admin.js';
|
|
28
29
|
import { ensureStationDeps } from '../stations/runtime-deps.js';
|
|
@@ -159,6 +160,7 @@ async function main(): Promise<void> {
|
|
|
159
160
|
.catch((err: unknown) => {
|
|
160
161
|
log.warn({ err: errMsg(err) }, 'plugin: could not ensure the Claude Code plugin');
|
|
161
162
|
});
|
|
163
|
+
watchSession();
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
const SHUTDOWN_TIMEOUT_MS = 3_000;
|
|
@@ -170,6 +172,7 @@ async function shutdown(): Promise<void> {
|
|
|
170
172
|
if (shuttingDown) return;
|
|
171
173
|
shuttingDown = true;
|
|
172
174
|
log.info('dispatcher shutting down');
|
|
175
|
+
unwatchSession();
|
|
173
176
|
connectors?.stop();
|
|
174
177
|
tunnel?.stop();
|
|
175
178
|
if (webhookServer) {
|
|
@@ -21,6 +21,14 @@ import {
|
|
|
21
21
|
startClaudeLogin,
|
|
22
22
|
type LoginDeps,
|
|
23
23
|
} from './login.js';
|
|
24
|
+
import {
|
|
25
|
+
ensureSession,
|
|
26
|
+
sessionStatus,
|
|
27
|
+
setAutostart,
|
|
28
|
+
startSession,
|
|
29
|
+
stopSession,
|
|
30
|
+
type SessionDeps,
|
|
31
|
+
} from './session.js';
|
|
24
32
|
import {
|
|
25
33
|
claudeDir,
|
|
26
34
|
deleteClaudeSession,
|
|
@@ -41,6 +49,7 @@ export interface ClaudeApiDeps {
|
|
|
41
49
|
authorize: (subject: string) => void;
|
|
42
50
|
dir?: () => string;
|
|
43
51
|
login?: LoginDeps;
|
|
52
|
+
session?: SessionDeps;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
function projectOf(query: URLSearchParams): string {
|
|
@@ -101,6 +110,31 @@ async function created(req: IncomingMessage, path: string, dir: string): Promise
|
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
const LOGIN = 'login';
|
|
113
|
+
const SESSION = 'session';
|
|
114
|
+
|
|
115
|
+
function sessionCommand(body: Record<string, unknown>, session: SessionDeps): unknown {
|
|
116
|
+
if (body.action === 'start') {
|
|
117
|
+
const status = sessionStatus(session);
|
|
118
|
+
if (status.running) return status;
|
|
119
|
+
if (status.blocked !== null) throw new ApiError(`cannot start a Claude session: ${status.blocked}`, 409);
|
|
120
|
+
return startSession(session);
|
|
121
|
+
}
|
|
122
|
+
if (body.action === 'stop') return stopSession(session);
|
|
123
|
+
if (body.action !== undefined) throw new ApiError('action must be start or stop', 400);
|
|
124
|
+
if (body.autostart === true) ensureSession(session);
|
|
125
|
+
return sessionStatus(session);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function sessionAnswer(req: IncomingMessage, deps: ClaudeApiDeps): Promise<unknown> {
|
|
129
|
+
const session = deps.session ?? {};
|
|
130
|
+
const method = req.method ?? 'GET';
|
|
131
|
+
if (method === 'GET') return sessionStatus(session);
|
|
132
|
+
if (method !== 'POST') throw new ApiError('method not allowed', 405);
|
|
133
|
+
const body = await readJsonBody(req);
|
|
134
|
+
if (!isRecord(body)) throw new ApiError('a body is required', 400);
|
|
135
|
+
if (typeof body.autostart === 'boolean') setAutostart(body.autostart, session.agents);
|
|
136
|
+
return sessionCommand(body, session);
|
|
137
|
+
}
|
|
104
138
|
|
|
105
139
|
async function loginAnswer(req: IncomingMessage, id: string, deps: ClaudeApiDeps): Promise<unknown> {
|
|
106
140
|
const method = req.method ?? 'GET';
|
|
@@ -136,6 +170,16 @@ function answer(method: string, path: string, query: URLSearchParams, dir: strin
|
|
|
136
170
|
return handler(query, dir, item);
|
|
137
171
|
}
|
|
138
172
|
|
|
173
|
+
function routed(req: IncomingMessage, path: string, search: string, deps: ClaudeApiDeps): unknown {
|
|
174
|
+
const dir = (deps.dir ?? claudeDir)();
|
|
175
|
+
const [head = '', item = ''] = parts(path);
|
|
176
|
+
if (head === LOGIN) return loginAnswer(req, item, deps);
|
|
177
|
+
if (head === SESSION && item === '') return sessionAnswer(req, deps);
|
|
178
|
+
if (req.method === 'PUT') return writeAnswer(req, path, dir);
|
|
179
|
+
if (req.method === 'POST') return created(req, path, dir);
|
|
180
|
+
return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
|
|
181
|
+
}
|
|
182
|
+
|
|
139
183
|
export function handleClaudeRequest(
|
|
140
184
|
req: IncomingMessage,
|
|
141
185
|
res: ServerResponse,
|
|
@@ -155,12 +199,7 @@ export function handleClaudeRequest(
|
|
|
155
199
|
.then((session) => {
|
|
156
200
|
if (!session) throw new ApiError('unauthorized', 401);
|
|
157
201
|
deps.authorize(session.subject);
|
|
158
|
-
|
|
159
|
-
const [head = '', item = ''] = parts(path);
|
|
160
|
-
if (head === LOGIN) return loginAnswer(req, item, deps);
|
|
161
|
-
if (req.method === 'PUT') return writeAnswer(req, path, dir);
|
|
162
|
-
if (req.method === 'POST') return created(req, path, dir);
|
|
163
|
-
return answer(req.method ?? 'GET', path, new URLSearchParams(search), dir);
|
|
202
|
+
return routed(req, path, search, deps);
|
|
164
203
|
})
|
|
165
204
|
.then((body) => {
|
|
166
205
|
sendJson(req, res, 200, body);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
@@ -32,3 +32,25 @@ export function markOnboardingDone(path = claudeConfigPath()): OnboardingMark {
|
|
|
32
32
|
writeFileSync(path, `${JSON.stringify({ ...config, [FLAG]: true }, null, 2)}\n`, { mode: 0o600 });
|
|
33
33
|
return 'marked';
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
const TRUST = 'hasTrustDialogAccepted';
|
|
37
|
+
|
|
38
|
+
function realDir(dir: string): string {
|
|
39
|
+
try {
|
|
40
|
+
return realpathSync(dir);
|
|
41
|
+
} catch {
|
|
42
|
+
return dir;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function trustFolder(dir: string, path = claudeConfigPath()): OnboardingMark {
|
|
47
|
+
const config = readConfig(path);
|
|
48
|
+
if (config === null) return 'unreadable';
|
|
49
|
+
const key = realDir(dir);
|
|
50
|
+
const projects = isRecord(config.projects) ? config.projects : {};
|
|
51
|
+
const project = isRecord(projects[key]) ? projects[key] : {};
|
|
52
|
+
if (project[TRUST] === true) return 'already';
|
|
53
|
+
const next = { ...config, projects: { ...projects, [key]: { ...project, [TRUST]: true } } };
|
|
54
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
55
|
+
return 'marked';
|
|
56
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
5
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
+
import { readJson, writeJson } from '@metro-labs/core/secure-fs';
|
|
7
|
+
import { agentsDir, listAgentFiles } from '../agents/files.js';
|
|
8
|
+
import { notReady, readModelConfig } from '../gateway/model-config.js';
|
|
9
|
+
import { claudeAccount, claudeInstalled } from './login.js';
|
|
10
|
+
import { trustFolder } from './onboarding.js';
|
|
11
|
+
|
|
12
|
+
export const SESSION_NAME = 'metro';
|
|
13
|
+
const STATE_FILE = 'claude-session.json';
|
|
14
|
+
const WARNING = 'WARNING: Loading development channels';
|
|
15
|
+
const CONFIRM_POLL_MS = 500;
|
|
16
|
+
const CONFIRM_WAIT_MS = 60_000;
|
|
17
|
+
const WATCH_MS = 15_000;
|
|
18
|
+
const STRIKE_WINDOW_MS = 60_000;
|
|
19
|
+
const MAX_STRIKES = 5;
|
|
20
|
+
const PAUSE_MS = 30 * 60_000;
|
|
21
|
+
|
|
22
|
+
export interface SessionDeps {
|
|
23
|
+
tmux?: string;
|
|
24
|
+
metro?: string[];
|
|
25
|
+
home?: string;
|
|
26
|
+
agents?: string;
|
|
27
|
+
signedIn?: () => boolean;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SessionStatus {
|
|
32
|
+
name: string;
|
|
33
|
+
running: boolean;
|
|
34
|
+
autostart: boolean;
|
|
35
|
+
blocked: string | null;
|
|
36
|
+
lastStartedAt: string | null;
|
|
37
|
+
lastError: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Memory {
|
|
41
|
+
lastStartedAt: number | null;
|
|
42
|
+
lastError: string | null;
|
|
43
|
+
strikes: number;
|
|
44
|
+
pausedUntil: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const memory: Memory = { lastStartedAt: null, lastError: null, strikes: 0, pausedUntil: 0 };
|
|
48
|
+
|
|
49
|
+
const statePath = (agents: string): string => join(agents, STATE_FILE);
|
|
50
|
+
|
|
51
|
+
export function autostartEnabled(agents = agentsDir()): boolean {
|
|
52
|
+
const raw = readJson<unknown>(statePath(agents), null);
|
|
53
|
+
return !isRecord(raw) || raw.autostart !== false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function setAutostart(enabled: boolean, agents = agentsDir()): void {
|
|
57
|
+
writeJson(statePath(agents), { autostart: enabled });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tmuxOk(tmux: string, args: string[]): boolean {
|
|
61
|
+
const run = spawnSync(tmux, args, { stdio: 'ignore' });
|
|
62
|
+
return run.error === undefined && run.status === 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const sessionRunning = (tmux = 'tmux'): boolean => tmuxOk(tmux, ['has-session', '-t', SESSION_NAME]);
|
|
66
|
+
|
|
67
|
+
function metroCommand(deps: SessionDeps): string[] {
|
|
68
|
+
if (deps.metro !== undefined) return deps.metro;
|
|
69
|
+
const bin = process.env.METRO_CLI_BIN?.trim() ?? '';
|
|
70
|
+
return bin === '' ? ['metro', 'claude'] : [process.execPath, bin, 'claude'];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function credentialReady(deps: SessionDeps): string | null {
|
|
74
|
+
const signedIn = deps.signedIn ?? (() => claudeAccount().signedIn);
|
|
75
|
+
if (signedIn()) return null;
|
|
76
|
+
try {
|
|
77
|
+
const cfg = readModelConfig(deps.agents ?? agentsDir());
|
|
78
|
+
if (cfg.provider !== 'anthropic' && notReady(cfg) === null) return null;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return `the Model page is not readable (${errMsg(err)})`;
|
|
81
|
+
}
|
|
82
|
+
return 'Claude Code is not signed in and the Model page routes nowhere yet';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function sessionBlocked(deps: SessionDeps = {}): string | null {
|
|
86
|
+
const agents = deps.agents ?? agentsDir();
|
|
87
|
+
if (listAgentFiles(agents).length === 0) return 'no agent on this machine yet';
|
|
88
|
+
if (deps.metro === undefined && !claudeInstalled()) return 'Claude Code is not installed on this machine';
|
|
89
|
+
if (!tmuxOk(deps.tmux ?? 'tmux', ['-V'])) return 'tmux is not installed on this machine';
|
|
90
|
+
return credentialReady(deps);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function capturePane(tmux: string): string {
|
|
94
|
+
const run = spawnSync(tmux, ['capture-pane', '-p', '-t', SESSION_NAME], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
95
|
+
return run.error === undefined && run.status === 0 ? run.stdout : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function confirmChannels(tmux: string, until: number): void {
|
|
99
|
+
const tick = (): void => {
|
|
100
|
+
const pane = capturePane(tmux);
|
|
101
|
+
if (pane.includes(WARNING) && pane.includes('server:metro')) {
|
|
102
|
+
spawnSync(tmux, ['send-keys', '-t', SESSION_NAME, 'Enter'], { stdio: 'ignore' });
|
|
103
|
+
log.info('claude-session: confirmed the development channels dialog for server:metro');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (Date.now() < until && sessionRunning(tmux)) setTimeout(tick, CONFIRM_POLL_MS).unref();
|
|
107
|
+
};
|
|
108
|
+
setTimeout(tick, CONFIRM_POLL_MS).unref();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function startSession(deps: SessionDeps = {}): SessionStatus {
|
|
112
|
+
const tmux = deps.tmux ?? 'tmux';
|
|
113
|
+
const home = deps.home ?? homedir();
|
|
114
|
+
const now = (deps.now ?? Date.now)();
|
|
115
|
+
const trusted = trustFolder(home);
|
|
116
|
+
const [command = 'metro', ...args] = metroCommand(deps);
|
|
117
|
+
const run = spawnSync(tmux, ['new-session', '-d', '-s', SESSION_NAME, '-c', home, '-x', '200', '-y', '50', command, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], cwd: home });
|
|
118
|
+
memory.lastStartedAt = now;
|
|
119
|
+
if (run.error !== undefined || run.status !== 0) {
|
|
120
|
+
memory.lastError = run.error === undefined ? `tmux new-session exited ${String(run.status)}: ${run.stderr.trim()}` : errMsg(run.error);
|
|
121
|
+
log.warn({ err: memory.lastError }, 'claude-session: tmux could not start');
|
|
122
|
+
} else {
|
|
123
|
+
memory.lastError = null;
|
|
124
|
+
log.info({ home, trusted, command: [command, ...args].join(' ') }, 'claude-session: started Claude Code in tmux');
|
|
125
|
+
confirmChannels(tmux, now + CONFIRM_WAIT_MS);
|
|
126
|
+
}
|
|
127
|
+
return sessionStatus(deps);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function stopSession(deps: SessionDeps = {}): SessionStatus {
|
|
131
|
+
spawnSync(deps.tmux ?? 'tmux', ['kill-session', '-t', SESSION_NAME], { stdio: 'ignore' });
|
|
132
|
+
return sessionStatus(deps);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function sessionStatus(deps: SessionDeps = {}): SessionStatus {
|
|
136
|
+
return {
|
|
137
|
+
name: SESSION_NAME,
|
|
138
|
+
running: sessionRunning(deps.tmux ?? 'tmux'),
|
|
139
|
+
autostart: autostartEnabled(deps.agents ?? agentsDir()),
|
|
140
|
+
blocked: sessionBlocked(deps),
|
|
141
|
+
lastStartedAt: memory.lastStartedAt === null ? null : new Date(memory.lastStartedAt).toISOString(),
|
|
142
|
+
lastError: memory.lastError,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function strike(now: number): boolean {
|
|
147
|
+
if (memory.lastStartedAt !== null && now - memory.lastStartedAt < STRIKE_WINDOW_MS) memory.strikes += 1;
|
|
148
|
+
else memory.strikes = 1;
|
|
149
|
+
if (memory.strikes < MAX_STRIKES) return false;
|
|
150
|
+
memory.pausedUntil = now + PAUSE_MS;
|
|
151
|
+
memory.strikes = 0;
|
|
152
|
+
log.warn({ pauseMinutes: PAUSE_MS / 60_000 }, 'claude-session: Claude Code keeps exiting right after starting; not restarting it for a while');
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function ensureSession(deps: SessionDeps = {}): 'started' | 'running' | 'blocked' | 'off' | 'paused' {
|
|
157
|
+
const now = (deps.now ?? Date.now)();
|
|
158
|
+
if (!autostartEnabled(deps.agents ?? agentsDir())) return 'off';
|
|
159
|
+
if (sessionRunning(deps.tmux ?? 'tmux')) return 'running';
|
|
160
|
+
if (now < memory.pausedUntil) return 'paused';
|
|
161
|
+
if (sessionBlocked(deps) !== null) return 'blocked';
|
|
162
|
+
if (strike(now)) return 'paused';
|
|
163
|
+
startSession(deps);
|
|
164
|
+
return 'started';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let watcher: ReturnType<typeof setInterval> | null = null;
|
|
168
|
+
|
|
169
|
+
export function watchSession(deps: SessionDeps = {}, everyMs = WATCH_MS): void {
|
|
170
|
+
if (watcher !== null) return;
|
|
171
|
+
const tick = (): void => {
|
|
172
|
+
try {
|
|
173
|
+
const outcome = ensureSession(deps);
|
|
174
|
+
if (outcome === 'started') log.info('claude-session: started');
|
|
175
|
+
} catch (err) {
|
|
176
|
+
log.warn({ err: errMsg(err) }, 'claude-session: check failed');
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
watcher = setInterval(tick, everyMs);
|
|
180
|
+
watcher.unref();
|
|
181
|
+
setTimeout(tick, 1_000).unref();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function unwatchSession(): void {
|
|
185
|
+
if (watcher !== null) clearInterval(watcher);
|
|
186
|
+
watcher = null;
|
|
187
|
+
}
|
package/runtime/runtime.json
CHANGED