starmux 0.14.0 → 0.15.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/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.15.0
4
+
5
+ ### Instance lifecycle
6
+
7
+ - Version and validate the local instance control protocol independently of package releases.
8
+ - Report `unknown` when control records or channels cannot establish the instance's state; do not mistake a missing control channel for a stopped service.
9
+ - Confirm cleanup before `down` reports success. Repeated stops report `Already stopped`; failed or timed-out cleanup returns a nonzero exit code.
10
+ - Use the same stop confirmation for instance archival and protect successor runs during concurrent operations.
11
+ - Separate confirmed active configuration, saved settings and historical snapshots in CLI and JSON output.
12
+ - Prevent `up` from changing permissions in an unknown state, prevent `doctor` from passing unresolved runtime errors, and show unknown states in the macOS App.
13
+ - Add isolated lifecycle and real CLI regression tests, validated on macOS and NVIDIA NX Linux ARM64.
14
+
15
+ ### Upgrade
16
+
17
+ Stop each running instance with the currently installed CLI before upgrading, then start it again with the new CLI. This release introduces a versioned instance control contract and does not migrate old runtime directories or support the previous unversioned contract. Replacing installed files does not update an already-running supervisor.
18
+
19
+ ```sh
20
+ starmux ls
21
+ starmux down # Default instance
22
+ starmux down -i NAME # Repeat for other running instances
23
+ npm install -g starmux@0.15.0
24
+ starmux up
25
+ ```
26
+
27
+ Proceed with installation only after stopping succeeds. If the current CLI cannot contact its supervisor, resolve that runtime problem first; a port conflict is not proof of successful shutdown.
package/README.md CHANGED
@@ -39,6 +39,8 @@ Requires **Node.js 22.12+**.
39
39
  npm install -g starmux
40
40
  ```
41
41
 
42
+ **Upgrading an existing installation?** Stop each running instance with the current CLI (`starmux down`, or `starmux down -i NAME`) before installing the new version, then run `starmux up` again. See the [upgrade instructions](https://github.com/cookyecat/starmux/blob/main/docs/INSTALL.md#upgrade) and [release notes](https://github.com/cookyecat/starmux/blob/main/CHANGELOG.md).
43
+
42
44
  ### 2. Start in your project directory
43
45
 
44
46
  ```bash
@@ -6,7 +6,7 @@ import { createAccessControl } from '../access-control.js';
6
6
  import { createWorkspaceGrants } from '../workspace-grants.js';
7
7
  import { createDenyRules } from '../explicit-deny-rules.js';
8
8
  import { createPrivateDirectory } from '../platform.js';
9
- import { instanceStatus, instanceRequest } from '../instance-runtime.js';
9
+ import { instanceStatus, instanceRequest, stopInstance } from '../instance-runtime.js';
10
10
  import { atomicPrivate, changeRegistry, configJSONSchema, sourceRevision, configTemplate, validateConfig, withLock, createInstance, exists, fingerprint, instancePaths, loadInstanceConfig, parseConfig, patchInstanceConfig, readRegistry, readTrusted, resolveInstance, saveInstanceConfig, suggestPort } from '../instance-store.js';
11
11
  const booleans = new Set(['--json', '--all', '--link', '--archived', '--approve-trusted-developer', '--approve-unrestricted', '--effective', '--allow-loopback-http']);
12
12
  function options(args) {
@@ -65,16 +65,17 @@ export async function handle(command, args) {
65
65
  : 'No instances. Run: starmux up\n');
66
66
  return;
67
67
  }
68
- const rows = await Promise.all(instances.map(async (s, idx) => {
69
- const raw = filtered[idx];
70
- const cfg = s.active?.config ?? (await loadInstanceConfig(raw).catch(() => null))?.config;
68
+ const rows = instances.map(s => {
69
+ const cfg = s.phase === 'stopped' ? s.saved?.config : s.active?.config;
70
+ const saved = s.saved?.config;
71
71
  const mark = s.instanceId === registry.defaultInstanceId ? '*' : ' ';
72
72
  const state = s.archived ? 'archived' : s.configState === 'pending' ? `${s.phase} (pending)` : s.phase;
73
- const mode = cfg?.accessRole === 'trusted' ? 'coding' : (s.workspaceMode ?? 'read-only');
73
+ const describe = (value) => !value ? 'unavailable' : value.accessRole === 'trusted' ? 'coding' : value.mode;
74
+ const mode = s.phase === 'stopped' ? `${describe(saved)} (saved)` : s.phase === 'unknown' ? `unavailable / ${describe(saved)} (saved)` : s.configState === 'pending' ? `${describe(cfg)} / ${describe(saved)} (saved)` : describe(cfg);
74
75
  const port = String(s.port ?? '—');
75
- const workspace = cfg?.projectRoot ?? s.source.path;
76
+ const workspace = cfg?.projectRoot ?? (saved ? `${saved.projectRoot} (saved)` : s.source.path);
76
77
  return { mark, name: s.name, state, mode, port, workspace };
77
- }));
78
+ });
78
79
  const wName = Math.max(4, ...rows.map(r => r.name.length));
79
80
  const wState = Math.max(6, ...rows.map(r => r.state.length));
80
81
  const wMode = Math.max(4, ...rows.map(r => r.mode.length));
@@ -121,9 +122,9 @@ export async function handle(command, args) {
121
122
  else if (op === 'default')
122
123
  registry.defaultInstanceId = item.id;
123
124
  else if (op === 'archive' || op === 'remove') {
124
- const stopped = await instanceRequest(instance, 'stop');
125
- if (stopped && stopped.phase !== 'stopped')
126
- throw Error('INSTANCE_STILL_RUNNING');
125
+ const stopped = await stopInstance(instance);
126
+ if (stopped.restarted)
127
+ throw Error('INSTANCE_RESTARTED: cannot archive a new running instance');
127
128
  item.archived = true;
128
129
  if (registry.defaultInstanceId === item.id)
129
130
  registry.defaultInstanceId = null;
@@ -163,11 +164,12 @@ export async function handle(command, args) {
163
164
  }
164
165
  if (op === 'show') {
165
166
  if (flags.has('--effective')) {
166
- const active = (await instanceStatus(instance)).active;
167
+ const status = await instanceStatus(instance);
168
+ const active = status.active;
167
169
  if (json)
168
170
  output(active);
169
171
  else if (!active)
170
- process.stdout.write(`Instance ${instance.name} has no active running configuration.\n`);
172
+ process.stdout.write(`Instance ${instance.name} has no confirmed active configuration (state: ${status.phase}).\n`);
171
173
  else
172
174
  process.stdout.write(`Instance ${instance.name} (effective running config)\n` +
173
175
  `Workspace ${active.config.projectRoot}\n` +
@@ -205,7 +207,7 @@ export async function handle(command, args) {
205
207
  return;
206
208
  }
207
209
  if (op === 'recover') {
208
- const snapshot = (await instanceStatus(instance)).active;
210
+ const snapshot = (await instanceStatus(instance)).lastActive;
209
211
  if (!snapshot)
210
212
  throw Error('NO_SUCCESSFUL_CONFIG_SNAPSHOT');
211
213
  await validateConfig(snapshot.config);
@@ -291,7 +293,14 @@ export async function handle(command, args) {
291
293
  return;
292
294
  }
293
295
  if (op === 'apply') {
296
+ const before = await instanceStatus(instance);
297
+ if (before.phase !== 'running' && before.phase !== 'stopped')
298
+ throw Error(`INSTANCE_NOT_READY: ${before.runtimeProblem ?? before.phase}`);
294
299
  const state = await instanceRequest(instance, 'apply', flags.get('--revision') ?? loaded.revision);
300
+ if (state && state.phase !== 'running')
301
+ throw Error('INSTANCE_APPLY_UNVERIFIED');
302
+ if (!state && before.phase === 'running')
303
+ throw Error('INSTANCE_CHANGED_DURING_APPLY');
295
304
  if (json)
296
305
  output({ result: state ? 'APPLIED' : 'VALID_ON_NEXT_START', instanceId: instance.id, runtime: state });
297
306
  else
@@ -23,9 +23,8 @@ async function authentication(instance, status) {
23
23
  }
24
24
  }
25
25
  export async function formatStarmuxStatus(instance, status, config, options = {}) {
26
- // Pending edits must never be shown as permissions of the running instance.
27
- if (status.phase === 'running')
28
- config = status.active?.config;
26
+ // Only the live supervisor can confirm active permissions.
27
+ config = status.phase === 'stopped' ? status.saved?.config : status.active?.config;
29
28
  const auth = await authentication(instance, status);
30
29
  // Omit `-i default` for the default instance to keep everyday single-instance output clean.
31
30
  const omitSelector = options.isDefault === true && instance.name === 'default';
@@ -40,23 +39,35 @@ export async function formatStarmuxStatus(instance, status, config, options = {}
40
39
  'api-key': `API key (Bearer token; ${auth.activeKeys} active ${auth.activeKeys === 1 ? 'key' : 'keys'})`,
41
40
  oauth: 'OAuth (browser sign-in)'
42
41
  };
43
- const saved = status.phase === 'running' ? ' (saved; live state unavailable)' : ' (saved for next start)';
42
+ const saved = status.phase === 'stopped' ? ' (saved for next start)' : ' (saved; live state unavailable)';
44
43
  const row = (label, value) => ' ' + label.padEnd(16) + value;
45
44
  const codingCmd = command('up --code');
46
45
  const globalMode = status.phase === 'running' ? status.runtime?.permissionMode === 'global' : !!config && usesGlobalPermissions(config);
47
- const mode = !config
46
+ const describeMode = (value, global) => !value
48
47
  ? 'Unavailable'
49
- : config.accessRole === 'trusted'
50
- ? `${config.writeScope === 'home-only' ? 'Coding (Home sandbox · edits, shell & git)' : 'Coding (OS account · edits, shell & git)'} (run '${command('up --read')}' to return to read-only)`
51
- : config.mode === 'read-write'
48
+ : value.accessRole === 'trusted'
49
+ ? value.writeScope === 'home-only' ? 'Coding (Home sandbox · edits, shell & git)' : 'Coding (OS account · edits, shell & git)'
50
+ : value.mode === 'read-write'
52
51
  ? 'File writes (restricted)'
53
- : `Read-only (${globalMode ? 'global · OS account' : 'project scope'}) (run '${codingCmd}' to enable edits & shell)`;
52
+ : `Read-only (${global ? 'global · OS account' : 'project scope'})`;
53
+ const switchHint = config && (status.phase === 'running' || status.phase === 'stopped')
54
+ ? config.accessRole === 'trusted' ? ` (run '${command('up --read')}' to return to read-only)` : ` (run '${codingCmd}' to enable edits & shell)`
55
+ : '';
56
+ const mode = describeMode(config, globalMode) + switchHint;
54
57
  const title = `${indicator} Starmux is ${status.phase} (instance: ${instance.name})`;
55
58
  const lines = ['', title, row('Instance', instance.id)];
56
59
  if (ready && status.port) {
57
60
  lines.push(row('Listen', `http://127.0.0.1:${status.port}/mcp (this computer only)`));
58
61
  }
59
- lines.push(row('Project', config?.projectRoot ?? 'Unavailable'), row('Mode', mode), row('Auth', auth.mode ? labels[auth.mode] + (ready ? '' : saved) : 'Unavailable'));
62
+ lines.push(row(status.phase === 'stopped' ? 'Saved project' : 'Project', config?.projectRoot ?? 'Unavailable'), row(status.phase === 'stopped' ? 'Saved mode' : status.phase === 'unknown' || status.configState === 'pending' ? 'Active mode' : 'Mode', mode), row('Auth', auth.mode ? labels[auth.mode] + (ready ? '' : saved) : 'Unavailable'));
63
+ if (status.phase !== 'stopped' && (status.configState === 'pending' || !status.active)) {
64
+ const savedConfig = status.saved?.config;
65
+ lines.push(row('Saved mode', describeMode(savedConfig, !!savedConfig && usesGlobalPermissions(savedConfig)) + (status.configState === 'pending' ? ' (pending)' : '')));
66
+ if (!status.active)
67
+ lines.push(row('Saved project', savedConfig?.projectRoot ?? 'Unavailable'));
68
+ }
69
+ if (status.runtime)
70
+ lines.push(row('Runtime', status.runtime.buildVersion));
60
71
  if (ready && status.port && auth.mode === 'oauth' && auth.oauth) {
61
72
  lines.push(row('Client URL', auth.oauth.resource), row('Issuer', auth.oauth.issuer));
62
73
  }
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
9
9
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
10
10
  import { createAccessControl } from '../access-control.js';
11
- import { instanceStatus, instanceRequest, runInstance } from '../instance-runtime.js';
11
+ import { instanceStatus, instanceRequest, stopInstance, runInstance } from '../instance-runtime.js';
12
12
  import { atomicPrivate, availablePort, changeRegistry, configJSONSchema, createInstance, ensureAccessReady, exists, instanceConfigSchema, instancePaths, loadInstanceConfig, parseConfig, patchInstanceConfig, readRegistry, readTrusted, resolveInstance, saveInstanceConfig, suggestPort, validateConfig, usesGlobalPermissions } from '../instance-store.js';
13
13
  import { handle } from './instances.js';
14
14
  import { packageRoot, desktopCommanderEntry } from '../runtime-paths.js';
@@ -125,6 +125,11 @@ async function configure() {
125
125
  instance = await resolveInstance();
126
126
  if (instance?.archived)
127
127
  fail('Instance is archived. Use starmux config restore -i ' + instance.name);
128
+ if (command === 'up' && instance) {
129
+ const state = await instanceStatus(instance);
130
+ if (state.phase !== 'running' && state.phase !== 'stopped')
131
+ fail(`Cannot start ${instance.name}: ${state.runtimeProblem ?? state.phase}. Run starmux doctor -i ${instance.id}.`);
132
+ }
128
133
  const loaded = instance ? await loadInstanceConfig(instance) : undefined;
129
134
  const writing = flags.write || flags.code;
130
135
  if (flags.read && writing)
@@ -160,21 +165,29 @@ async function show(instance) {
160
165
  print(status);
161
166
  return;
162
167
  }
163
- const config = status.phase === 'running' ? status.active?.config : (await loadInstanceConfig(instance).catch(() => null))?.config;
168
+ const config = status.phase === 'stopped' ? status.saved?.config : status.active?.config;
164
169
  process.stdout.write(await formatStarmuxStatus(instance, status, config, { isDefault: await isDefaultInstance(instance) }));
165
170
  }
166
171
  async function start(instance) {
167
172
  const state = await instanceStatus(instance);
168
173
  if (state.phase === 'running') {
169
- if (state.configState === 'pending')
170
- await instanceRequest(instance, 'apply', (await loadInstanceConfig(instance)).revision);
174
+ if (state.configState === 'pending') {
175
+ const applied = await instanceRequest(instance, 'apply', (await loadInstanceConfig(instance)).revision);
176
+ if (!applied || applied.phase !== 'running')
177
+ fail('Configuration was not applied: instance changed while starting. Run starmux status.');
178
+ }
179
+ const current = await instanceStatus(instance);
180
+ if (current.phase !== 'running' || current.health !== 'ok')
181
+ fail(`Instance is not ready: ${current.runtimeProblem ?? current.health}. Run starmux doctor -i ${instance.id}.`);
171
182
  await show(instance);
172
183
  return;
173
184
  }
185
+ if (state.phase !== 'stopped')
186
+ fail(`Cannot start ${instance.name}: ${state.runtimeProblem ?? state.phase}. Run starmux doctor -i ${instance.id}.`);
174
187
  await ensureAccessReady(instance);
175
188
  const config = (await loadInstanceConfig(instance)).config;
176
189
  if (!await availablePort(config.port))
177
- fail(`Port ${config.port} is in use. Choose another with starmux up --port PORT.`);
190
+ fail(`Port ${config.port} is in use. Inspect starmux doctor -i ${instance.id} before changing the port.`);
178
191
  if (flags.foreground || string('app-parent-pid')) {
179
192
  await runInstance(instance, { json, ...(!json ? { onReady: () => show(instance) } : {}), ...(string('app-parent-pid') ? { appParentPid: Number(string('app-parent-pid')) } : {}) });
180
193
  return;
@@ -645,11 +658,13 @@ async function main() {
645
658
  if (command === 'status')
646
659
  await show(instance);
647
660
  else if (command === 'down' || command === 'stop') {
648
- await instanceRequest(instance, 'stop');
661
+ const result = await stopInstance(instance);
649
662
  if (json)
650
- print({ result: 'STOPPED', instanceId: instance.id });
663
+ print(result);
664
+ else if (result.restarted)
665
+ process.stdout.write(`Stopped the previous run of ${instance.name}; another command has started a new run.\n`);
651
666
  else
652
- process.stdout.write('Stopped ' + instance.name + '\n');
667
+ process.stdout.write(`${result.result === 'ALREADY_STOPPED' ? 'Already stopped' : 'Stopped'} ${instance.name}\n`);
653
668
  }
654
669
  else if (command === 'auth')
655
670
  await auth(instance);
@@ -662,23 +677,30 @@ async function main() {
662
677
  else if (command === 'doctor') {
663
678
  if (Number(process.versions.node.split('.')[0]) < 22 || (process.versions.node.startsWith('22.') && Number(process.versions.node.split('.')[1]) < 12))
664
679
  fail('Node 22.12 or newer is required.');
680
+ const state = await instanceStatus(instance);
681
+ if (state.configProblem || state.runtimeProblem || state.runtime?.problem)
682
+ fail(state.configProblem ?? state.runtimeProblem ?? state.runtime.problem);
683
+ if (state.phase !== 'running' && state.phase !== 'stopped')
684
+ fail('Instance state is ' + state.phase + '. Inspect starmux logs -i ' + instance.id);
685
+ if (state.phase === 'running' && state.health !== 'ok')
686
+ fail('Instance health is ' + state.health + '. Inspect starmux logs -i ' + instance.id);
665
687
  await stat(desktopCommanderEntry);
666
688
  const loaded = await loadInstanceConfig(instance);
667
689
  await ensureAccessReady(instance);
668
- const state = await instanceStatus(instance);
669
- if (state.phase !== 'running' && !await availablePort(loaded.config.port))
690
+ if (state.phase === 'stopped' && !await availablePort(loaded.config.port))
670
691
  fail('Configured port is in use: ' + loaded.config.port);
671
692
  if (json)
672
693
  print({ result: 'PASS', node: process.version, instanceId: instance.id, configState: state.configState });
673
694
  else {
674
695
  const isDef = await isDefaultInstance(instance);
675
696
  const access = await (await createAccessControl(instancePaths(instance).access, { initialize: false })).snapshot();
676
- const modeLabel = loaded.config.accessRole === 'trusted' ? 'Coding mode' : 'Read-only mode';
697
+ const effective = state.phase === 'running' ? state.active?.config : loaded.config;
698
+ const modeLabel = !effective ? 'Mode unavailable' : effective.accessRole === 'trusted' ? 'Coding mode' : effective.mode === 'read-write' ? 'File writes' : 'Read-only mode';
677
699
  const authLabel = access.mode === 'ingress' ? 'None (no token required)' : access.mode === 'api-key' ? `API key (${access.credentials.filter(c => c.status === 'active').length} active)` : 'OAuth';
678
700
  const portState = state.phase === 'running' ? `Running on 127.0.0.1:${state.port}` : `Stopped · port ${loaded.config.port} available`;
679
701
  process.stdout.write(`✓ Runtime Node ${process.version} & Desktop Commander engine ready\n` +
680
702
  `✓ Service ${portState}${isDef ? '' : ` (instance: ${instance.name})`}\n` +
681
- `✓ Workspace ${loaded.config.projectRoot} (${modeLabel})\n` +
703
+ `✓ Workspace ${effective?.projectRoot ?? 'Unavailable'} (${modeLabel})\n` +
682
704
  `✓ Configuration ${instance.source.path} (${state.configState})\n` +
683
705
  `✓ Authentication ${authLabel}\n` +
684
706
  `PASS · Node, dependencies, workspace, configuration, port and authentication.\n`);
@@ -2,4 +2,4 @@ import { z } from 'zod';
2
2
  export const grantsSchema = z.object({ version: z.literal(1), homeRead: z.boolean(), outsideRead: z.boolean(), custom: z.array(z.object({ path: z.string(), mode: z.enum(['read-only', 'read-write']) }).strict()) }).strict();
3
3
  export const denySchema = z.object({ version: z.literal(1), deniedTools: z.array(z.string()), deniedDirectories: z.array(z.string()) }).strict();
4
4
  export const upstreamDefinitionSchema = z.object({ id: z.string(), transport: z.literal('stdio'), command: z.string(), args: z.array(z.string()), env: z.record(z.string()).optional(), allowTools: z.array(z.string()) }).strict();
5
- export const ownerSchema = z.object({ port: z.number().int(), pid: z.number().int().min(2), token: z.string().regex(/^[a-f0-9]{48}$/), launcher: z.enum(['cli', 'app']), runId: z.string().regex(/^[a-f0-9]{32}$/) }).passthrough();
5
+ export const ownerSchema = z.object({ version: z.literal(1), port: z.number().int(), pid: z.number().int().min(2), token: z.string().regex(/^[a-f0-9]{48}$/), launcher: z.enum(['cli', 'app']), runId: z.string().regex(/^[a-f0-9]{32}$/) }).passthrough();
@@ -1,6 +1,7 @@
1
1
  import { ownedByUser, createPrivateDirectory, controlRoot, windows } from "./platform.js";
2
2
  import { ownerSchema } from "./config-schemas.js";
3
3
  import { GatewayError, errorCode, isSafeInteger, parseJSON, record } from "./validation.js";
4
+ const CONTROL_VERSION = 1;
4
5
  // One Core / two equal frontends. Owner-only local supervisor control.
5
6
  // The socket is never an MCP/public endpoint, and never signals a discovered PID.
6
7
  import crypto from "node:crypto";
@@ -39,7 +40,10 @@ function readOwner(paths) {
39
40
  const d = fs.readFileSync(paths.meta);
40
41
  if (d.length > 1024)
41
42
  deny("CORE_CONTROL_METADATA_INVALID");
42
- const checked = ownerSchema.safeParse(parseJSON(d.toString("utf8")));
43
+ const metadata = record(parseJSON(d.toString("utf8")), "CORE_CONTROL_METADATA_INVALID");
44
+ if (metadata.version !== CONTROL_VERSION)
45
+ deny("CORE_CONTROL_PROTOCOL_UNSUPPORTED");
46
+ const checked = ownerSchema.safeParse(metadata);
43
47
  if (!checked.success)
44
48
  deny("CORE_CONTROL_METADATA_INVALID");
45
49
  const owner = checked.data;
@@ -71,7 +75,7 @@ async function exchange(paths, owner, method, timeoutMs = 11500) {
71
75
  resolve(result);
72
76
  };
73
77
  client.once("connect", () => client.write(JSON.stringify({
74
- version: 1, ...(windows ? { nonce, proof: proof(owner.token, nonce + ":" + owner.runId + ":" + method) } : { token: owner.token }), runId: owner.runId, method
78
+ version: CONTROL_VERSION, ...(windows ? { nonce, proof: proof(owner.token, nonce + ":" + owner.runId + ":" + method) } : { token: owner.token }), runId: owner.runId, method
75
79
  }) + "\n"));
76
80
  client.on("data", d => {
77
81
  body += d.toString("utf8");
@@ -83,8 +87,10 @@ async function exchange(paths, owner, method, timeoutMs = 11500) {
83
87
  const reply = record(parseJSON(body.split("\n")[0]));
84
88
  if (windows && reply.proof !== proof(owner.token, nonce + ":" + String(reply.result)))
85
89
  throw Error("CORE_CONTROL_REPLY_UNAUTHENTICATED");
86
- if (reply.runId !== owner.runId || !["RUNNING", "STARTING", "STOPPED",
87
- "STOP_FAILED"].includes(String(reply.result)))
90
+ if (reply.version !== CONTROL_VERSION || reply.result === "PROTOCOL_UNSUPPORTED")
91
+ throw Error("CORE_CONTROL_PROTOCOL_UNSUPPORTED");
92
+ if (reply.runId !== owner.runId || !["RUNNING", "STARTING", "STOPPING", "STOPPED",
93
+ "STOP_ACCEPTED", "STOP_FAILED"].includes(String(reply.result)))
88
94
  throw Error("CORE_CONTROL_REPLY_INVALID");
89
95
  finish(null, { result: String(reply.result), runId: owner.runId, launcher: owner.launcher });
90
96
  }
@@ -153,35 +159,70 @@ function cleanStale(paths) {
153
159
  catch { }
154
160
  }
155
161
  }
156
- export async function connectOwner(port, { root } = {}) {
162
+ export function inspectControlOwner(port, { root } = {}) {
157
163
  const paths = controlPaths(port, root);
158
- let owner;
159
164
  try {
160
- owner = readOwner(paths);
165
+ return readOwner(paths);
161
166
  }
162
167
  catch (e) {
163
- if (["ENOENT"].includes(errorCode(e) || ""))
168
+ if (errorCode(e) === "ENOENT" && !fs.existsSync(paths.lock))
164
169
  return null;
165
170
  throw e;
166
171
  }
172
+ }
173
+ export async function connectOwner(port, { root } = {}) {
174
+ const paths = controlPaths(port, root);
175
+ const owner = inspectControlOwner(port, { root });
176
+ if (!owner || !alive(owner.pid))
177
+ return null;
167
178
  const result = await exchange(paths, owner, "status", 1500);
168
179
  return { owner, result };
169
180
  }
170
- export async function globalStop(port, { root } = {}) {
181
+ export async function globalStop(port, { root, timeoutMs = 65000 } = {}) {
182
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
183
+ deny("CORE_STOP_TIMEOUT_INVALID");
171
184
  const paths = controlPaths(port, root);
172
185
  let owner;
173
186
  try {
174
187
  owner = readOwner(paths);
175
188
  }
176
189
  catch (e) {
177
- if (errorCode(e) === "ENOENT")
190
+ if (errorCode(e) === "ENOENT" && !fs.existsSync(paths.lock))
178
191
  return { result: "NO_CONTROL_OWNER" };
179
192
  throw e;
180
193
  }
181
- const result = await exchange(paths, owner, "stop");
182
- if (result.result !== "STOPPED")
194
+ const deadline = Date.now() + timeoutMs;
195
+ const result = await exchange(paths, owner, "stop", Math.min(11500, timeoutMs));
196
+ if (result.result !== "STOP_ACCEPTED")
183
197
  deny("CORE_STOP_UNVERIFIED");
184
- return { result: "STOPPED", launcher: owner.launcher, ownerPid: owner.pid };
198
+ // The acknowledgment only accepts the request. Completion belongs to this
199
+ // lease, not to whatever may bind the same port after it has been released.
200
+ while (Date.now() < deadline) {
201
+ try {
202
+ const current = readOwner(paths);
203
+ if (current.runId !== owner.runId)
204
+ return { result: "STOPPED", launcher: owner.launcher, ownerPid: owner.pid, restarted: true };
205
+ try {
206
+ const state = await exchange(paths, owner, "status", Math.min(250, Math.max(1, deadline - Date.now())));
207
+ if (state.result === "STOP_FAILED")
208
+ deny("CORE_STOP_FAILED");
209
+ }
210
+ catch (e) {
211
+ // Closing the control listener is part of releasing the lease.
212
+ if (!["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(errorCode(e) ?? "") &&
213
+ !["CORE_CONTROL_EOF", "CORE_CONTROL_TIMEOUT"].includes(e instanceof Error ? e.message : ""))
214
+ throw e;
215
+ }
216
+ }
217
+ catch (e) {
218
+ if (errorCode(e) !== "ENOENT")
219
+ throw e;
220
+ if (!fs.existsSync(paths.lock))
221
+ return { result: "STOPPED", launcher: owner.launcher, ownerPid: owner.pid, restarted: false };
222
+ }
223
+ await delay(Math.min(50, Math.max(1, deadline - Date.now())));
224
+ }
225
+ deny("CORE_STOP_TIMEOUT");
185
226
  }
186
227
  export async function acquireControl(port, { root, launcher = "cli" } = {}) {
187
228
  if (!["cli", "app"].includes(launcher))
@@ -226,18 +267,21 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
226
267
  deny("CORE_CONTROL_CLAIM_TIMEOUT");
227
268
  }
228
269
  secureDir(paths.lock);
229
- const owner = { version: 1, port, pid: process.pid, launcher,
270
+ const owner = { version: CONTROL_VERSION, port, pid: process.pid, launcher,
230
271
  token: crypto.randomBytes(24).toString("hex"),
231
272
  runId: crypto.randomBytes(16).toString("hex") };
232
273
  fs.writeFileSync(paths.meta, JSON.stringify(owner), { mode: 0o600, flag: "wx" });
233
- let onStop = null, phase = "STARTING";
274
+ let onStop = null, phase = "STARTING", stopAccepted = false;
234
275
  const clients = new Set();
235
276
  const server = net.createServer(socket => {
236
277
  clients.add(socket);
237
278
  socket.setTimeout(12000, () => socket.destroy());
279
+ socket.on("error", () => { });
238
280
  socket.once("close", () => clients.delete(socket));
239
- let raw = "";
240
- socket.on("data", async (chunk) => {
281
+ let raw = "", handled = false;
282
+ socket.on("data", chunk => {
283
+ if (handled)
284
+ return;
241
285
  raw += chunk.toString("utf8");
242
286
  if (raw.length > 2048) {
243
287
  socket.destroy();
@@ -246,7 +290,7 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
246
290
  if (!raw.includes("\n"))
247
291
  return;
248
292
  const line = raw.split("\n")[0];
249
- raw = "";
293
+ handled = true;
250
294
  let request;
251
295
  try {
252
296
  request = record(parseJSON(line));
@@ -255,24 +299,30 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
255
299
  socket.destroy();
256
300
  return;
257
301
  }
258
- if (request.version !== 1 ||
259
- (windows ? typeof request.nonce !== "string" || request.nonce.length !== 48 || request.proof !== proof(owner.token, request.nonce + ":" + owner.runId + ":" + String(request.method)) : request.token !== owner.token) ||
302
+ if ((windows ? typeof request.nonce !== "string" || request.nonce.length !== 48 || request.proof !== proof(owner.token, request.nonce + ":" + owner.runId + ":" + String(request.method)) : request.token !== owner.token) ||
260
303
  request.runId !== owner.runId) {
261
304
  socket.destroy();
262
305
  return;
263
306
  }
264
- let result = "STOP_FAILED";
265
- if (request.method === "status")
307
+ let result = "STOP_FAILED", startStop = false;
308
+ if (request.version !== CONTROL_VERSION)
309
+ result = "PROTOCOL_UNSUPPORTED";
310
+ else if (request.method === "status")
266
311
  result = phase;
267
312
  else if (request.method === "stop" && onStop) {
268
- try {
269
- result = await onStop();
270
- }
271
- catch {
272
- result = "STOP_FAILED";
313
+ if (phase !== "STOP_FAILED") {
314
+ result = "STOP_ACCEPTED";
315
+ startStop = !stopAccepted;
316
+ stopAccepted = true;
317
+ phase = "STOPPING";
273
318
  }
274
319
  }
275
- socket.end(JSON.stringify({ result, runId: owner.runId, launcher: owner.launcher, ...(windows ? { proof: proof(owner.token, String(request.nonce) + ":" + result) } : {}) }) + "\n");
320
+ socket.end(JSON.stringify({ version: CONTROL_VERSION, result, runId: owner.runId, launcher: owner.launcher, ...(windows ? { proof: proof(owner.token, String(request.nonce) + ":" + result) } : {}) }) + "\n", () => {
321
+ // Never await shutdown on its own control connection: release() waits
322
+ // for these sockets to close. Acceptance and completion are distinct.
323
+ if (startStop)
324
+ void Promise.resolve().then(() => onStop()).catch(() => { phase = "STOP_FAILED"; });
325
+ });
276
326
  });
277
327
  });
278
328
  try {
@@ -301,36 +351,39 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
301
351
  catch { }
302
352
  throw e;
303
353
  }
304
- let closing = false;
354
+ let releasePromise = null;
305
355
  return {
306
356
  owner,
307
357
  markRunning() { phase = "RUNNING"; },
308
358
  markStopped() { phase = "STOPPED"; },
309
359
  onStop(fn) { onStop = fn; },
310
- async release() {
311
- if (closing)
312
- return;
313
- closing = true;
314
- // Response sockets close before the lease disappears.
315
- await new Promise(resolve => {
316
- server.close(resolve);
317
- setTimeout(() => { for (const s of clients)
318
- s.destroy(); }, 1200).unref();
319
- });
320
- if (readOwner(paths).runId !== owner.runId)
321
- deny("CORE_CONTROL_LEASE_CHANGED");
322
- // node:net automatically unlinks a Unix socket on server.close().
323
- // A second unlink is not required and must not abort lease cleanup.
324
- try {
325
- if (!windows)
326
- fs.unlinkSync(paths.socket);
327
- }
328
- catch (e) {
329
- if (errorCode(e) !== "ENOENT")
330
- throw e;
331
- }
332
- fs.unlinkSync(paths.meta);
333
- fs.rmdirSync(paths.lock);
360
+ release() {
361
+ return releasePromise ??= (async () => {
362
+ // Response sockets close before the lease disappears.
363
+ await new Promise((resolve, reject) => {
364
+ const timer = setTimeout(() => { for (const s of clients)
365
+ s.destroy(); }, 1200);
366
+ timer.unref();
367
+ server.close(error => { clearTimeout(timer); if (error)
368
+ reject(error);
369
+ else
370
+ resolve(); });
371
+ });
372
+ if (readOwner(paths).runId !== owner.runId)
373
+ deny("CORE_CONTROL_LEASE_CHANGED");
374
+ // node:net automatically unlinks a Unix socket on server.close().
375
+ // A second unlink is not required and must not abort lease cleanup.
376
+ try {
377
+ if (!windows)
378
+ fs.unlinkSync(paths.socket);
379
+ }
380
+ catch (e) {
381
+ if (errorCode(e) !== "ENOENT")
382
+ throw e;
383
+ }
384
+ fs.unlinkSync(paths.meta);
385
+ fs.rmdirSync(paths.lock);
386
+ })();
334
387
  }
335
388
  };
336
389
  }
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { instanceConfigSchema } from './instance-store.js';
3
+ // Version the local control contract independently of npm releases. Unknown
4
+ // versions fail explicitly; additional descriptive fields are safe to ignore.
5
+ export const INSTANCE_CONTROL_VERSION = 1;
6
+ const runId = z.string().regex(/^[a-f0-9]{32}$/);
7
+ export const instanceOwnerSchema = z.object({
8
+ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), instanceId: z.string().uuid(), runId,
9
+ pid: z.number().int().min(2), port: z.number().int().min(1024).max(65535),
10
+ token: z.string().regex(/^[a-f0-9]{64}$/), launcher: z.enum(['cli', 'app']), appParentPid: z.number().int().nullable()
11
+ });
12
+ export const activeConfigSchema = z.object({ config: instanceConfigSchema, revision: z.string(), effectiveRevision: z.string(), sourcePath: z.string() });
13
+ export const runtimeStateSchema = z.object({
14
+ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), instanceId: z.string().uuid(), runId, pid: z.number().int().min(2),
15
+ launcher: z.enum(['cli', 'app']), appParentPid: z.number().int().nullable(), executionMode: z.enum(['foreground', 'background']).optional(),
16
+ permissionMode: z.enum(['global', 'workspace']).optional(), phase: z.enum(['starting', 'running', 'stopping', 'stopped', 'error']),
17
+ port: z.number().int().min(1024).max(65535).nullable(), active: activeConfigSchema.nullable(), problem: z.string().nullable(),
18
+ coreProgram: z.string(), buildVersion: z.string()
19
+ });
20
+ export const controlRequestSchema = z.object({ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), method: z.enum(['status', 'stop', 'apply']),
21
+ revision: z.string().nullable(), runId, nonce: z.string().regex(/^[a-f0-9]{48}$/) });
22
+ export const controlReplySchema = z.object({ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), state: runtimeStateSchema,
23
+ result: z.enum(['STATE', 'STOP_ACCEPTED']), error: z.string().optional() });
24
+ export function checkInstanceProtocol(value) {
25
+ if (!value || typeof value !== 'object' || !('protocolVersion' in value) || value.protocolVersion !== INSTANCE_CONTROL_VERSION)
26
+ throw Error('INSTANCE_CONTROL_PROTOCOL_UNSUPPORTED');
27
+ }
@@ -1,17 +1,17 @@
1
1
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { createServer, createConnection } from 'node:net';
3
3
  import { fork } from 'node:child_process';
4
- import { mkdir, rm, readFile, open, lstat } from 'node:fs/promises';
4
+ import { mkdir, rm, open } from 'node:fs/promises';
5
+ import { setTimeout as pause } from 'node:timers/promises';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import path from 'node:path';
7
8
  import { homedir } from 'node:os';
8
- import { z } from 'zod';
9
+ import { INSTANCE_CONTROL_VERSION, instanceOwnerSchema, activeConfigSchema, runtimeStateSchema, controlRequestSchema, controlReplySchema, checkInstanceProtocol } from './instance-control-protocol.js';
9
10
  import { controlRoot, createPrivateDirectory, childEnvKeys, windows } from './platform.js';
10
- import { acquireControl } from './global-core-control.js';
11
+ import { acquireControl, inspectControlOwner } from './global-core-control.js';
11
12
  import { runtimeIdentity } from './runtime-identity.js';
12
13
  import { atomicPrivate, changeRegistry, availablePort, ensureAccessReady, exists, instanceEnv, instancePaths, loadInstanceConfig, prepareUpstreams, readTrusted, resolveInstance } from './instance-store.js';
13
- const metadataSchema = z.object({ instanceId: z.string().uuid(), runId: z.string().regex(/^[a-f0-9]{32}$/), pid: z.number().int().min(2), token: z.string().regex(/^[a-f0-9]{64}$/), launcher: z.enum(['cli', 'app']), appParentPid: z.number().int().nullable() }).strict();
14
- function runtimePaths(id) {
14
+ export function runtimePaths(id) {
15
15
  const root = path.join(controlRoot(), 'i-' + id);
16
16
  const socket = windows ? '\\\\.\\pipe\\gateway-instance-' + createHash('sha256').update(root).digest('hex').slice(0, 28) : path.join(root, 'control.sock');
17
17
  return { root, socket, meta: path.join(root, 'owner.json') };
@@ -29,21 +29,21 @@ async function ownerFor(instance) {
29
29
  const p = runtimePaths(instance.id);
30
30
  if (!await exists(p.root))
31
31
  return null;
32
- const owner = metadataSchema.parse(JSON.parse(await readTrusted(p.meta)));
32
+ const raw = JSON.parse(await readTrusted(p.meta));
33
+ checkInstanceProtocol(raw);
34
+ const owner = instanceOwnerSchema.parse(raw);
33
35
  if (owner.instanceId !== instance.id)
34
36
  throw Error('INSTANCE_CONTROL_IDENTITY_INVALID');
35
37
  return owner;
36
38
  }
37
- export async function instanceRequest(instance, method, revision) {
38
- const owner = await ownerFor(instance);
39
- if (!owner || !alive(owner.pid))
40
- return null;
39
+ async function exchange(instance, owner, method, revision, timeoutMs = method === 'apply' ? 65000 : 2000) {
41
40
  const nonce = randomBytes(24).toString('hex');
42
- const body = JSON.stringify({ method, revision: revision ?? null, runId: owner.runId, nonce });
41
+ const body = JSON.stringify({ protocolVersion: INSTANCE_CONTROL_VERSION, method, revision: revision ?? null, runId: owner.runId, nonce });
43
42
  return new Promise((resolve, reject) => {
44
43
  const socket = createConnection(runtimePaths(instance.id).socket);
45
44
  let raw = '';
46
- socket.setTimeout(method === 'status' ? 2000 : 65000, () => socket.destroy(Error('INSTANCE_CONTROL_TIMEOUT')));
45
+ const timer = setTimeout(() => socket.destroy(Error('INSTANCE_CONTROL_TIMEOUT')), timeoutMs);
46
+ socket.once('close', () => clearTimeout(timer));
47
47
  socket.on('error', reject);
48
48
  socket.on('connect', () => socket.end(JSON.stringify({ body, proof: signature(owner.token, body) }) + '\n'));
49
49
  socket.on('data', data => { raw += String(data); if (raw.length > 524288)
@@ -51,14 +51,18 @@ export async function instanceRequest(instance, method, revision) {
51
51
  socket.on('end', () => {
52
52
  try {
53
53
  const reply = JSON.parse(raw);
54
- if (!equal(reply.proof, signature(owner.token, nonce + reply.body)))
54
+ if (typeof reply.body !== 'string' || !equal(reply.proof, signature(owner.token, nonce + reply.body)))
55
55
  throw Error('INSTANCE_CONTROL_REPLY_UNTRUSTED');
56
- const data = JSON.parse(reply.body);
56
+ const parsed = JSON.parse(reply.body);
57
+ checkInstanceProtocol(parsed);
58
+ const data = controlReplySchema.parse(parsed);
57
59
  if (data.error)
58
60
  throw Error(data.error);
59
61
  if (data.state.instanceId !== instance.id || data.state.runId !== owner.runId)
60
62
  throw Error('INSTANCE_CONTROL_IDENTITY_CHANGED');
61
- resolve(data.state);
63
+ if (data.result !== (method === 'stop' ? 'STOP_ACCEPTED' : 'STATE'))
64
+ throw Error('INSTANCE_CONTROL_REPLY_INVALID');
65
+ resolve(data);
62
66
  }
63
67
  catch (e) {
64
68
  reject(e);
@@ -66,64 +70,139 @@ export async function instanceRequest(instance, method, revision) {
66
70
  });
67
71
  });
68
72
  }
73
+ export async function instanceRequest(instance, method, revision) {
74
+ const owner = await ownerFor(instance);
75
+ if (!owner || !alive(owner.pid))
76
+ return null;
77
+ return (await exchange(instance, owner, method, revision)).state;
78
+ }
79
+ async function snapshot(file, schema) {
80
+ try {
81
+ return schema.parse(JSON.parse(await readTrusted(file)));
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
69
87
  export async function instanceStatus(instance, home = homedir()) {
70
88
  let runtime = null;
71
89
  let runtimeProblem = null;
90
+ let owner = null;
72
91
  try {
73
- runtime = await instanceRequest(instance, 'status');
92
+ owner = await ownerFor(instance);
93
+ if (owner && alive(owner.pid))
94
+ runtime = (await exchange(instance, owner, 'status')).state;
74
95
  }
75
96
  catch (e) {
76
97
  runtimeProblem = e.message;
77
98
  }
78
- let saved = null;
99
+ let loaded = null;
79
100
  let configProblem = null;
80
101
  try {
81
- saved = await loadInstanceConfig(instance);
102
+ loaded = await loadInstanceConfig(instance);
82
103
  }
83
104
  catch (e) {
84
105
  configProblem = e.message;
85
106
  }
86
- let lastRuntime = null;
87
- if (!runtime)
88
- try {
89
- lastRuntime = JSON.parse(await readTrusted(path.join(instancePaths(instance, home).snapshots, 'last-runtime.json')));
90
- }
91
- catch { }
92
- if (!runtime && lastRuntime?.problem)
93
- runtimeProblem ??= lastRuntime.problem;
107
+ const saved = loaded ? { config: loaded.config, revision: loaded.revision, effectiveRevision: loaded.effectiveRevision, sourcePath: loaded.source.path } : null;
108
+ const snapshots = instancePaths(instance, home).snapshots;
109
+ const lastRun = await snapshot(path.join(snapshots, 'last-runtime.json'), runtimeStateSchema);
110
+ const lastActive = await snapshot(path.join(snapshots, 'active.json'), activeConfigSchema);
94
111
  let health = 'not-running';
95
112
  let accessMode = null;
96
113
  let lastToolCallAt = null;
114
+ if (!runtime && !runtimeProblem) {
115
+ // Historical ports are hints for detecting a lost control channel, never
116
+ // authority to stop a process or claim a configuration is currently active.
117
+ const ports = new Set([owner?.port, saved?.config.port, lastActive?.config.port].filter((p) => p !== undefined));
118
+ for (const port of ports) {
119
+ if (!await availablePort(port)) {
120
+ runtimeProblem = 'INSTANCE_CONTROL_UNAVAILABLE: port ' + port + ' is in use; ownership is unconfirmed';
121
+ break;
122
+ }
123
+ try {
124
+ const lease = inspectControlOwner(port);
125
+ if (lease && alive(lease.pid)) {
126
+ runtimeProblem = 'INSTANCE_CONTROL_UNAVAILABLE: live port lease ' + port;
127
+ break;
128
+ }
129
+ }
130
+ catch (e) {
131
+ runtimeProblem = e.message;
132
+ break;
133
+ }
134
+ }
135
+ }
97
136
  if (runtime?.phase === 'running' && runtime.port) {
98
137
  try {
99
138
  const response = await fetch(`http://127.0.0.1:${runtime.port}/healthz`, { signal: AbortSignal.timeout(1500) });
100
139
  const data = await response.json();
101
140
  health = response.ok && data.status === 'ok' && data.instanceId === instance.id ? 'ok' : 'unrecognized';
102
141
  accessMode = data.accessMode ?? null;
103
- const activity = await fetch(`http://127.0.0.1:${runtime.port}/activity.json`, { signal: AbortSignal.timeout(1000) }).then(r => r.json());
104
- lastToolCallAt = typeof activity.lastToolCallAt === 'string' ? activity.lastToolCallAt : null;
142
+ if (health === 'ok')
143
+ try {
144
+ const activity = await fetch(`http://127.0.0.1:${runtime.port}/activity.json`, { signal: AbortSignal.timeout(1000) }).then(r => r.json());
145
+ lastToolCallAt = typeof activity.lastToolCallAt === 'string' ? activity.lastToolCallAt : null;
146
+ }
147
+ catch { /* Activity is optional; it must not turn a healthy service into an outage. */ }
105
148
  }
106
149
  catch {
107
150
  health = 'unreachable';
108
151
  }
109
152
  }
110
- let active = runtime?.active ?? null;
111
- if (!active)
112
- try {
113
- active = JSON.parse(await readTrusted(path.join(instancePaths(instance, home).snapshots, 'active.json')));
114
- }
115
- catch { }
153
+ const active = runtime?.active ?? null;
154
+ const phase = runtime?.phase ?? (runtimeProblem ? 'unknown' : 'stopped');
155
+ if (phase === 'unknown')
156
+ health = 'unknown';
116
157
  const configState = configProblem ? (configProblem.includes('ENOENT') ? 'missing' : 'invalid') : runtime?.phase === 'running' ? (saved?.effectiveRevision === active?.effectiveRevision ? 'applied' : 'pending') : 'on-next-start';
117
158
  return { schemaVersion: 1, event: 'GATEWAY_STATUS', instanceId: instance.id, name: instance.name, archived: instance.archived, source: instance.source,
118
- phase: runtime?.phase ?? (runtimeProblem ? 'error' : 'stopped'), configState, configProblem, runtimeProblem, runtime,
159
+ phase, configState, configProblem, runtimeProblem, runtime, saved, lastActive, lastRun,
119
160
  port: runtime?.port ?? saved?.config.port ?? null, configuredPort: saved?.config.port ?? null, active, revision: saved?.revision ?? null,
120
161
  health, accessMode, lastToolCallAt, profile: instance.legacy ?? 'setup', installed: false, registered: false,
121
- label: instance.name, workspaceMode: runtime?.active?.config.mode ?? saved?.config.mode ?? null,
122
- writeScope: runtime?.active?.config.writeScope ?? saved?.config.writeScope ?? null, coreControl: runtime?.phase === 'running' ? 'RUNNING' : 'unavailable',
123
- coreLauncher: runtime?.launcher ?? 'unknown', coreOwnerPid: runtime?.pid ?? null, listenerPid: runtime?.pid ?? null,
124
- executionMode: runtime?.phase === 'running' ? (runtime.executionMode === 'background' ? 'background' : 'foreground-owned') : 'stopped', loginAutostart: 'disabled',
162
+ label: instance.name, workspaceMode: active?.config.mode ?? (phase === 'stopped' ? saved?.config.mode : null) ?? null,
163
+ writeScope: active?.config.writeScope ?? (phase === 'stopped' ? saved?.config.writeScope : null) ?? null, coreControl: runtime?.phase === 'running' ? 'RUNNING' : 'unavailable',
164
+ coreLauncher: runtime?.launcher ?? 'unknown', coreOwnerPid: runtime?.pid ?? null,
165
+ executionMode: runtime ? (runtime.executionMode === 'background' ? 'background' : 'foreground-owned') : phase === 'stopped' ? 'stopped' : 'unknown', loginAutostart: 'disabled',
125
166
  connectionState: health === 'ok' ? (lastToolCallAt ? 'local-ready-activity-recorded' : 'local-ready-remote-unverified') : health, requiredAccess: instance.requiredAccess };
126
167
  }
168
+ export async function stopInstance(instance, home = homedir(), options = {}) {
169
+ const timeoutMs = options.timeoutMs ?? 65000;
170
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
171
+ throw Error('INSTANCE_STOP_TIMEOUT_INVALID');
172
+ const deadline = Date.now() + timeoutMs;
173
+ const owner = await ownerFor(instance);
174
+ if (!owner || !alive(owner.pid)) {
175
+ const status = await instanceStatus(instance, home);
176
+ if (status.phase !== 'stopped')
177
+ throw Error(status.runtimeProblem ?? 'INSTANCE_STOP_UNVERIFIED');
178
+ return { result: 'ALREADY_STOPPED', instanceId: instance.id, runId: null, restarted: false };
179
+ }
180
+ await exchange(instance, owner, 'stop', undefined, Math.max(1, Math.min(2000, deadline - Date.now())));
181
+ const lastFile = path.join(instancePaths(instance, home).snapshots, 'last-runtime.json');
182
+ // The terminal snapshot is committed only after worker/lease/socket cleanup.
183
+ // Do not use a free port or a dead PID alone as proof of successful shutdown.
184
+ while (Date.now() < deadline) {
185
+ let current;
186
+ try {
187
+ current = await ownerFor(instance);
188
+ }
189
+ catch (e) {
190
+ if (e.code === 'ENOENT') {
191
+ await pause(25);
192
+ continue;
193
+ }
194
+ throw e;
195
+ }
196
+ const lastRun = await snapshot(lastFile, runtimeStateSchema);
197
+ if (lastRun?.runId === owner.runId && lastRun.phase === 'stopped' && current?.runId !== owner.runId) {
198
+ return { result: 'STOPPED', instanceId: instance.id, runId: owner.runId, restarted: current !== null };
199
+ }
200
+ if (lastRun?.runId === owner.runId && lastRun.phase === 'error')
201
+ throw Error('INSTANCE_STOP_FAILED: ' + lastRun.problem);
202
+ await pause(Math.min(50, Math.max(1, deadline - Date.now())));
203
+ }
204
+ throw Error('INSTANCE_STOP_TIMEOUT: cleanup was not confirmed; run starmux doctor');
205
+ }
127
206
  export async function runInstance(instance, options = {}) {
128
207
  const home = options.home ?? homedir();
129
208
  const parent = options.appParentPid;
@@ -133,9 +212,10 @@ export async function runInstance(instance, options = {}) {
133
212
  throw Error('INSTANCE_ARCHIVED');
134
213
  const initial = await loadInstanceConfig(instance);
135
214
  await ensureAccessReady(await resolveInstance(instance.id, home), home);
215
+ const identity = await runtimeIdentity();
136
216
  const p = runtimePaths(instance.id);
137
217
  createPrivateDirectory(controlRoot());
138
- const owner = { instanceId: instance.id, runId: randomBytes(16).toString('hex'), pid: process.pid, token: randomBytes(32).toString('hex'), launcher: parent ? 'app' : 'cli', appParentPid: parent ?? null };
218
+ const owner = { protocolVersion: INSTANCE_CONTROL_VERSION, port: initial.config.port, instanceId: instance.id, runId: randomBytes(16).toString('hex'), pid: process.pid, token: randomBytes(32).toString('hex'), launcher: parent ? 'app' : 'cli', appParentPid: parent ?? null };
139
219
  await changeRegistry(async (registry) => {
140
220
  const registered = registry.instances.find(i => i.id === instance.id);
141
221
  if (!registered || registered.archived)
@@ -165,14 +245,15 @@ export async function runInstance(instance, options = {}) {
165
245
  }
166
246
  await atomicPrivate(p.meta, JSON.stringify(owner));
167
247
  }, home);
168
- const identity = await runtimeIdentity();
169
- const state = { instanceId: instance.id, runId: owner.runId, pid: process.pid, launcher: owner.launcher, appParentPid: parent ?? null, executionMode: options.background ? 'background' : 'foreground', phase: 'starting', port: null, active: null, problem: null, ...identity };
248
+ const state = { protocolVersion: INSTANCE_CONTROL_VERSION, instanceId: instance.id, runId: owner.runId, pid: process.pid, launcher: owner.launcher, appParentPid: parent ?? null, executionMode: options.background ? 'background' : 'foreground', phase: 'starting', port: null, active: null, problem: null, ...identity };
170
249
  let worker = null;
171
250
  let portLease = null;
172
251
  let server = null;
173
252
  let ending = false;
174
253
  let expectedWorkerExit = false;
175
254
  let sequence = Promise.resolve();
255
+ let stopRequest = null;
256
+ const clients = new Set();
176
257
  let finish;
177
258
  const finished = new Promise(r => { finish = r; });
178
259
  const serialize = (fn) => { const next = sequence.then(fn); sequence = next.catch(() => { }); return next; };
@@ -189,22 +270,40 @@ export async function runInstance(instance, options = {}) {
189
270
  if (!child)
190
271
  return;
191
272
  expectedWorkerExit = true;
192
- await new Promise(resolve => {
273
+ await new Promise((resolve, reject) => {
193
274
  if (child.exitCode !== null || child.signalCode !== null) {
194
275
  resolve();
195
276
  return;
196
277
  }
197
278
  let forced = false;
198
- const timer = setTimeout(() => { forced = true; child.kill('SIGKILL'); }, 8000);
199
- child.once('exit', () => { clearTimeout(timer); if (forced)
200
- state.problem = 'WORKER_STOP_FORCED'; resolve(); });
279
+ const finish = (error) => { clearTimeout(grace); clearTimeout(deadline); child.off('exit', exited); child.off('error', failed); error ? reject(error) : resolve(); };
280
+ const exited = () => { if (forced)
281
+ state.problem = 'WORKER_STOP_FORCED'; finish(); };
282
+ const failed = (error) => finish(error);
283
+ const grace = setTimeout(() => { forced = true; try {
284
+ child.kill('SIGKILL');
285
+ }
286
+ catch (e) {
287
+ finish(e);
288
+ } }, 8000);
289
+ const deadline = setTimeout(() => finish(Error('INSTANCE_WORKER_STOP_TIMEOUT')), 12000);
290
+ child.once('exit', exited);
291
+ child.once('error', failed);
201
292
  if (child.connected)
202
- child.send({ type: 'gateway-stop' });
293
+ child.send({ type: 'gateway-stop' }, error => { if (error && child.exitCode === null && child.signalCode === null)
294
+ try {
295
+ child.kill('SIGTERM');
296
+ }
297
+ catch (e) {
298
+ finish(e);
299
+ } });
203
300
  else
204
301
  child.kill('SIGTERM');
205
302
  });
206
303
  worker = null;
207
304
  expectedWorkerExit = false;
305
+ state.active = null;
306
+ state.port = null;
208
307
  }
209
308
  async function shutdown(reason) {
210
309
  if (ending)
@@ -215,11 +314,24 @@ export async function runInstance(instance, options = {}) {
215
314
  await stopWorker();
216
315
  await portLease?.release();
217
316
  portLease = null;
218
- state.phase = 'stopped';
219
317
  state.port = null;
220
- emit('INSTANCE_STOPPED');
221
318
  finish();
222
319
  }
320
+ function requestStop(reason) {
321
+ if (!stopRequest) {
322
+ stopRequest = serialize(() => shutdown(reason));
323
+ void stopRequest.catch(async (error) => {
324
+ state.phase = 'error';
325
+ state.problem = 'INSTANCE_STOP_FAILED: ' + error.message;
326
+ emit('INSTANCE_ERROR');
327
+ try {
328
+ await atomicPrivate(path.join(instancePaths(instance, home).snapshots, 'last-runtime.json'), JSON.stringify(state));
329
+ }
330
+ catch { }
331
+ });
332
+ }
333
+ return stopRequest;
334
+ }
223
335
  async function startWorker(config, lease) {
224
336
  await ensureAccessReady(await resolveInstance(instance.id, home), home);
225
337
  if (!await availablePort(config.config.port))
@@ -263,13 +375,13 @@ export async function runInstance(instance, options = {}) {
263
375
  state.phase = 'error';
264
376
  state.problem = 'INSTANCE_WORKER_EXITED';
265
377
  emit('INSTANCE_ERROR');
266
- void serialize(() => shutdown('INSTANCE_WORKER_EXITED'));
378
+ void requestStop('INSTANCE_WORKER_EXITED');
267
379
  } });
268
380
  emit('INSTANCE_RUNNING');
269
381
  }
270
382
  const active = (loaded) => ({ config: loaded.config, revision: loaded.revision, effectiveRevision: loaded.effectiveRevision, sourcePath: loaded.source.path });
271
383
  async function apply(revision) {
272
- if (ending)
384
+ if (ending || stopRequest)
273
385
  throw Error('INSTANCE_STOPPING');
274
386
  const current = await resolveInstance(instance.id, home);
275
387
  const next = await loadInstanceConfig(current);
@@ -286,7 +398,7 @@ export async function runInstance(instance, options = {}) {
286
398
  if (!await availablePort(next.config.port))
287
399
  throw Error('PORT_IN_USE: ' + next.config.port);
288
400
  candidateLease = await acquireControl(next.config.port, { launcher: owner.launcher });
289
- candidateLease.onStop(async () => { void serialize(shutdown); return 'STOPPED'; });
401
+ candidateLease.onStop(() => requestStop());
290
402
  }
291
403
  state.phase = 'starting';
292
404
  await stopWorker();
@@ -315,7 +427,7 @@ export async function runInstance(instance, options = {}) {
315
427
  throw Error(state.problem);
316
428
  }
317
429
  }
318
- const signal = () => { void serialize(shutdown); };
430
+ const signal = () => { void requestStop(); };
319
431
  const parentTimer = parent ? setInterval(() => { if (process.ppid !== parent)
320
432
  signal(); }, 400) : null;
321
433
  parentTimer?.unref();
@@ -323,6 +435,8 @@ export async function runInstance(instance, options = {}) {
323
435
  process.on('SIGINT', signal);
324
436
  try {
325
437
  server = createServer({ allowHalfOpen: true }, socket => {
438
+ clients.add(socket);
439
+ socket.once('close', () => clients.delete(socket));
326
440
  socket.setTimeout(65000, () => socket.destroy());
327
441
  let raw = '';
328
442
  let handled = false;
@@ -344,21 +458,25 @@ export async function runInstance(instance, options = {}) {
344
458
  const envelope = JSON.parse(raw);
345
459
  if (typeof envelope.body !== 'string' || !equal(envelope.proof, signature(owner.token, envelope.body)))
346
460
  throw Error('CONTROL_DENIED');
347
- const req = JSON.parse(envelope.body);
348
- nonce = req.nonce;
349
- if (req.runId !== owner.runId || !/^[a-f0-9]{48}$/.test(nonce))
461
+ const parsed = JSON.parse(envelope.body);
462
+ // Recover the nonce before validating the version so an authenticated
463
+ // caller receives an explicit protocol error rather than a bad proof.
464
+ if (parsed && typeof parsed === 'object' && 'nonce' in parsed && typeof parsed.nonce === 'string')
465
+ nonce = parsed.nonce;
466
+ checkInstanceProtocol(parsed);
467
+ const req = controlRequestSchema.parse(parsed);
468
+ if (req.runId !== owner.runId)
350
469
  throw Error('CONTROL_DENIED');
351
- if (req.method === 'stop')
352
- await serialize(shutdown);
353
- else if (req.method === 'apply')
470
+ if (req.method === 'apply')
354
471
  await serialize(() => apply(req.revision));
355
- else if (req.method !== 'status')
356
- throw Error('CONTROL_INVALID_METHOD');
357
- const body = JSON.stringify({ state });
358
- socket.end(JSON.stringify({ body, proof: signature(owner.token, nonce + body) }) + '\n');
472
+ const body = JSON.stringify({ protocolVersion: INSTANCE_CONTROL_VERSION, state, result: req.method === 'stop' ? 'STOP_ACCEPTED' : 'STATE' });
473
+ socket.end(JSON.stringify({ body, proof: signature(owner.token, nonce + body) }) + '\n', () => {
474
+ if (req.method === 'stop')
475
+ void requestStop();
476
+ });
359
477
  }
360
478
  catch (error) {
361
- const body = JSON.stringify({ state, error: error.message });
479
+ const body = JSON.stringify({ protocolVersion: INSTANCE_CONTROL_VERSION, state, result: 'STATE', error: error.message });
362
480
  socket.end(JSON.stringify({ body, proof: signature(owner.token, nonce + body) }) + '\n');
363
481
  }
364
482
  })();
@@ -366,7 +484,7 @@ export async function runInstance(instance, options = {}) {
366
484
  });
367
485
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(p.socket, () => resolve()); });
368
486
  portLease = await acquireControl(initial.config.port, { launcher: owner.launcher });
369
- portLease.onStop(async () => { void serialize(shutdown); return 'STOPPED'; });
487
+ portLease.onStop(() => requestStop());
370
488
  await serialize(() => startWorker(active(initial), portLease));
371
489
  if (options.onReady)
372
490
  await options.onReady();
@@ -384,13 +502,34 @@ export async function runInstance(instance, options = {}) {
384
502
  clearInterval(parentTimer);
385
503
  process.off('SIGTERM', signal);
386
504
  process.off('SIGINT', signal);
387
- await stopWorker();
388
- await portLease?.release();
389
- if (server)
390
- await new Promise(resolve => server.close(() => resolve()));
391
- await atomicPrivate(path.join(instancePaths(instance, home).snapshots, 'last-runtime.json'), JSON.stringify(state));
392
- const current = await ownerFor(instance);
393
- if (current?.runId === owner.runId)
394
- await rm(p.root, { recursive: true, force: true });
505
+ try {
506
+ await stopWorker();
507
+ await portLease?.release();
508
+ if (server)
509
+ await new Promise((resolve, reject) => {
510
+ const timer = setTimeout(() => { for (const client of clients)
511
+ client.destroy(); }, 1500);
512
+ server.close(error => { clearTimeout(timer); error ? reject(error) : resolve(); });
513
+ });
514
+ if (ending)
515
+ state.phase = 'stopped';
516
+ // Commit completion last. A stop acknowledgement only means accepted;
517
+ // readers require this snapshot AND release of this run's owner record.
518
+ await atomicPrivate(path.join(instancePaths(instance, home).snapshots, 'last-runtime.json'), JSON.stringify(state));
519
+ const current = await ownerFor(instance);
520
+ if (current?.runId === owner.runId)
521
+ await rm(p.root, { recursive: true, force: true });
522
+ if (state.phase === 'stopped')
523
+ emit('INSTANCE_STOPPED');
524
+ }
525
+ catch (error) {
526
+ state.phase = 'error';
527
+ state.problem = 'INSTANCE_CLEANUP_FAILED: ' + error.message;
528
+ try {
529
+ await atomicPrivate(path.join(instancePaths(instance, home).snapshots, 'last-runtime.json'), JSON.stringify(state));
530
+ }
531
+ catch { }
532
+ throw error;
533
+ }
395
534
  }
396
535
  }
@@ -59,6 +59,9 @@ export function createPrivateDirectory(folder) {
59
59
  throw Error('PRIVATE_DIRECTORY_UNSAFE');
60
60
  }
61
61
  export function controlRoot() {
62
+ // Local control address contract: keep independent of the package version,
63
+ // install path, project directory and display name. A branding edit must not
64
+ // silently move the rendezvous point of an already-running supervisor.
62
65
  if (process.platform === 'darwin')
63
66
  return `/private/tmp/starmux-${process.getuid()}`;
64
67
  const id = windows ? createHash('sha256').update(homedir().toLowerCase()).digest('hex').slice(0, 16) : String(process.getuid());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starmux",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Connect AI clients to files, terminals and development tools on your own computer.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/cookyecat/starmux#readme",
@@ -31,6 +31,7 @@
31
31
  "status": "node bin/starmux.mjs status",
32
32
  "build": "node scripts/build.mjs",
33
33
  "typecheck": "tsc -p tsconfig.json --noEmit",
34
+ "test": "tsc -p tsconfig.json && node --test tests/*.test.mjs",
34
35
  "prepare": "node scripts/prepare.mjs",
35
36
  "prepack": "npm run build",
36
37
  "sync:upstream": "npm install --save-dev --save-exact @wonderwhy-er/desktop-commander@latest --ignore-scripts && npm run build"
@@ -63,6 +64,7 @@
63
64
  "bin/starmux.mjs",
64
65
  "scripts/prepare.mjs",
65
66
  "LICENSE",
67
+ "CHANGELOG.md",
66
68
  "README.md"
67
69
  ]
68
70
  }