@ccmsg/cli 0.1.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.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. package/src/version.ts +8 -0
@@ -0,0 +1,88 @@
1
+ import { PROTOCOL_VERSION } from "@ccmsg/protocol";
2
+
3
+ /** One short exchange over an instance's unix socket.
4
+ *
5
+ * The CLI asks one thing at a time, so a `request_id` is a counter and the
6
+ * answer is the next frame: nothing here needs correlation, and the connection
7
+ * lives no longer than the command that opened it. */
8
+ export interface Conn {
9
+ ask(request: Record<string, unknown>): Promise<Record<string, unknown>>;
10
+ /** The next frame the instance sends without having been asked for one: a
11
+ * topic frame, or an event about the connection itself. Every frame arrives
12
+ * on the one stream `ask` reads its reply from, so a caller that subscribes
13
+ * reads on with this instead of asking again. */
14
+ next(): Promise<Record<string, unknown>>;
15
+ close(): void;
16
+ }
17
+
18
+ /** The instance behind a socket path, or nothing when there is none. */
19
+ export async function connect(path: string): Promise<Conn | undefined> {
20
+ const replies = new Replies();
21
+ let socket: Bun.Socket<undefined>;
22
+ try {
23
+ socket = await Bun.connect({
24
+ unix: path,
25
+ socket: {
26
+ data(_socket, chunk) {
27
+ replies.push(chunk);
28
+ },
29
+ },
30
+ });
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ let counter = 0;
35
+ return {
36
+ ask(request) {
37
+ counter += 1;
38
+ socket.write(`${JSON.stringify({ request_id: `${counter}`, ...request })}\n`);
39
+ return replies.next();
40
+ },
41
+ next() {
42
+ return replies.next();
43
+ },
44
+ close() {
45
+ socket.end();
46
+ },
47
+ };
48
+ }
49
+
50
+ /** Greet as the person running the command.
51
+ *
52
+ * `role: "user"` because that is what the caller is: the lifecycle ops belong
53
+ * to whoever operates the host, not to a session speaking from inside a turn. */
54
+ export function greetAsUser(conn: Conn): Promise<Record<string, unknown>> {
55
+ return conn.ask({ op: "hello", role: "user", protocol_version: PROTOCOL_VERSION });
56
+ }
57
+
58
+ /** Reassemble the replies of one exchange, by arrival order. */
59
+ class Replies {
60
+ readonly #ready: Record<string, unknown>[] = [];
61
+ #waiting: ((frame: Record<string, unknown>) => void) | undefined;
62
+ #buffer = "";
63
+
64
+ push(chunk: Uint8Array): void {
65
+ this.#buffer += new TextDecoder().decode(chunk);
66
+ let at: number;
67
+ while ((at = this.#buffer.indexOf("\n")) >= 0) {
68
+ const line = this.#buffer.slice(0, at);
69
+ this.#buffer = this.#buffer.slice(at + 1);
70
+ if (line.trim() === "") continue;
71
+ const frame = JSON.parse(line) as Record<string, unknown>;
72
+ const waiting = this.#waiting;
73
+ if (waiting === undefined) this.#ready.push(frame);
74
+ else {
75
+ this.#waiting = undefined;
76
+ waiting(frame);
77
+ }
78
+ }
79
+ }
80
+
81
+ next(): Promise<Record<string, unknown>> {
82
+ const first = this.#ready.shift();
83
+ if (first !== undefined) return Promise.resolve(first);
84
+ return new Promise((resolve) => {
85
+ this.#waiting = resolve;
86
+ });
87
+ }
88
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./control.ts";
2
+ export * from "./link.ts";
3
+ export * from "./log.ts";
4
+ export * from "./registry.ts";
5
+ export * from "./snapshot.ts";
6
+ export * from "./supervise.ts";
@@ -0,0 +1,93 @@
1
+ import type { ErrorCode } from "@ccmsg/protocol";
2
+ import { type Env, resolveSupervisorSocket } from "../instance/paths.ts";
3
+ import { connect } from "./control.ts";
4
+
5
+ /** What a command asks the supervisor for.
6
+ *
7
+ * Its own protocol, not the contract's: these are requests about processes on
8
+ * this host, spoken over a socket no client outside it reaches, and nothing
9
+ * here is something a web UI or a mesh peer may ask. The `supervise_` prefix is
10
+ * what keeps the two from being read as one — an op name here is not an op
11
+ * name there, and no attribute table covers these. */
12
+ export const SUPERVISE_OPS = [
13
+ "supervise_start",
14
+ "supervise_stop",
15
+ "supervise_restart",
16
+ "supervise_status",
17
+ "supervise_add",
18
+ "supervise_remove",
19
+ ] as const;
20
+
21
+ export type SuperviseOp = (typeof SUPERVISE_OPS)[number];
22
+
23
+ export interface SuperviseRequest {
24
+ readonly op: SuperviseOp;
25
+ /** The config home the request is about. Absent with `all`. */
26
+ readonly dir?: string;
27
+ /** Every config home the supervisor looks after. */
28
+ readonly all?: boolean;
29
+ }
30
+
31
+ /** Why a command could not be carried out.
32
+ *
33
+ * The contract's codes wherever one fits, because a person reading a ccmsg
34
+ * error should not have to learn a second vocabulary for the same thing. The
35
+ * one addition is the failure the contract has no word for: there is no
36
+ * supervisor, which is not an op being refused but nobody being there to
37
+ * refuse it. */
38
+ export type CliErrorCode = ErrorCode | "supervisor_not_running";
39
+
40
+ /** The one command that answers without a supervisor is `daemon log`: a log is
41
+ * read after something died, and requiring the supervisor to be up would make
42
+ * the record unreadable exactly when it is wanted. */
43
+ export const NO_SUPERVISOR: CliErrorCode = "supervisor_not_running";
44
+
45
+ export class CommandError extends Error {
46
+ constructor(
47
+ readonly code: CliErrorCode,
48
+ msg: string,
49
+ /** What the command found before it stopped, for a failure that has more
50
+ * to say than a line: the same report the command would have answered
51
+ * with. Printed beside the error so a caller reads one shape whether the
52
+ * command worked or not. */
53
+ readonly detail?: unknown,
54
+ ) {
55
+ super(msg);
56
+ this.name = "CommandError";
57
+ }
58
+ }
59
+
60
+ export function noSupervisor(): CommandError {
61
+ return new CommandError(
62
+ NO_SUPERVISOR,
63
+ "監督者が動いていません (`ccmsg service start` か `ccmsg daemon supervise` で起動してください)",
64
+ );
65
+ }
66
+
67
+ /** Put one request to the supervisor, and answer with what it said.
68
+ *
69
+ * A connection per command: these are one exchange each, and a socket that
70
+ * outlived the command would be a client the supervisor has to keep track of
71
+ * for nothing. */
72
+ export async function ask(request: SuperviseRequest, env: Env = process.env): Promise<unknown> {
73
+ const conn = await connect(resolveSupervisorSocket(env));
74
+ if (conn === undefined) throw noSupervisor();
75
+ try {
76
+ const answer = await conn.ask({ ...request });
77
+ if (answer["ok"] === true) return answer["result"];
78
+ const error = answer["error"] as { code?: CliErrorCode; msg?: string } | undefined;
79
+ throw new CommandError(error?.code ?? "internal_error", error?.msg ?? JSON.stringify(answer));
80
+ } finally {
81
+ conn.close();
82
+ }
83
+ }
84
+
85
+ /** Whether a supervisor is there at all, for the two commands that carry on
86
+ * without one rather than failing: writing the config is worth doing whether or
87
+ * not anybody is listening for the news. */
88
+ export async function reachable(env: Env = process.env): Promise<boolean> {
89
+ const conn = await connect(resolveSupervisorSocket(env));
90
+ if (conn === undefined) return false;
91
+ conn.close();
92
+ return true;
93
+ }
@@ -0,0 +1,116 @@
1
+ import { type FSWatcher, existsSync, statSync, watch } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+
5
+ /** How much of a log is shown when one is opened.
6
+ *
7
+ * The reason to read a log is to find out what just happened, so what is wanted
8
+ * is its recent end rather than its history — and a bound is what keeps
9
+ * `daemon log --all` on a long-running host from being a whole day of lines
10
+ * before the first new one arrives. */
11
+ export const LOG_TAIL_BYTES = 256 * 1024;
12
+
13
+ /** Where reading a log left off, so following it starts where showing it ended
14
+ * and no line is shown twice. */
15
+ export interface Read {
16
+ readonly lines: readonly string[];
17
+ readonly end: number;
18
+ }
19
+
20
+ /** The bytes of `[from, to)`, cut back to the last complete line.
21
+ *
22
+ * A line still being written is left for the next read: the log's writer
23
+ * appends whole lines (`Log`), so half a line on disk is one being written
24
+ * right now rather than one that will stay half. */
25
+ async function readRange(file: string, from: number, to: number): Promise<Read> {
26
+ if (to <= from) return { lines: [], end: from };
27
+ const handle = await open(file, "r");
28
+ try {
29
+ const buffer = Buffer.alloc(to - from);
30
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, from);
31
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
32
+ const complete = text.slice(0, text.lastIndexOf("\n") + 1);
33
+ return {
34
+ lines: complete.split("\n").filter((line) => line !== ""),
35
+ end: from + Buffer.byteLength(complete),
36
+ };
37
+ } finally {
38
+ await handle.close();
39
+ }
40
+ }
41
+
42
+ /** The end of a log, and where it ends.
43
+ *
44
+ * The window starts at a byte rather than at a line, so the first line it lands
45
+ * inside is dropped: a fragment of a record is not a record. */
46
+ export async function tailOf(file: string, bytes = LOG_TAIL_BYTES): Promise<Read> {
47
+ if (!existsSync(file)) return { lines: [], end: 0 };
48
+ const size = statSync(file).size;
49
+ const from = Math.max(0, size - bytes);
50
+ const read = await readRange(file, from, size);
51
+ return from === 0 ? read : { lines: read.lines.slice(1), end: read.end };
52
+ }
53
+
54
+ /** Follow a file, on its own change notifications rather than on a clock.
55
+ *
56
+ * The watch is on the directory as well as on the file, because a log that does
57
+ * not exist yet is one a watch cannot attach to and the instance writing it may
58
+ * not have started.
59
+ *
60
+ * A file that shrank is one that was rotated or replaced, so reading continues
61
+ * from its start: what was there before is gone, and holding the old offset
62
+ * would skip everything written since. */
63
+ export function follow(
64
+ file: string,
65
+ from: number,
66
+ onLines: (lines: readonly string[]) => void,
67
+ ): { close(): void } {
68
+ let offset = from;
69
+ let reading: Promise<void> = Promise.resolve();
70
+ const watchers: FSWatcher[] = [];
71
+ const pull = (): void => {
72
+ reading = reading.then(async () => {
73
+ if (!existsSync(file)) return;
74
+ const size = statSync(file).size;
75
+ if (size < offset) offset = 0;
76
+ const read = await readRange(file, offset, size);
77
+ if (read.lines.length > 0) onLines(read.lines);
78
+ offset = read.end;
79
+ });
80
+ };
81
+ for (const at of new Set([file, dirname(file)])) {
82
+ try {
83
+ watchers.push(watch(at, pull));
84
+ } catch {
85
+ // Not there yet. The directory watch is what catches the file appearing;
86
+ // when neither attaches there is nothing to follow and nothing to report.
87
+ }
88
+ }
89
+ pull();
90
+ return {
91
+ close(): void {
92
+ for (const watcher of watchers) watcher.close();
93
+ },
94
+ };
95
+ }
96
+
97
+ /** One log line as it is shown, which is the line itself when it is JSON and a
98
+ * record carrying it when it is not.
99
+ *
100
+ * The instance writes JSON per line, so `--all` can add which instance a line
101
+ * came from by putting the fields beside it. A line from anywhere else — a
102
+ * crash the runtime printed, an init system's own words — is not JSON and is
103
+ * shown as text under `line`, because dropping it would hide exactly the
104
+ * failure somebody opened the log for. */
105
+ export function labelled(line: string, fields: Record<string, unknown>): string {
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(line);
109
+ } catch {
110
+ return JSON.stringify({ ...fields, line });
111
+ }
112
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
113
+ return JSON.stringify({ ...fields, line });
114
+ }
115
+ return JSON.stringify({ ...fields, ...(parsed as Record<string, unknown>) });
116
+ }
@@ -0,0 +1,285 @@
1
+ import { existsSync, mkdirSync, watch } from "node:fs";
2
+ import { basename, isAbsolute, join, resolve } from "node:path";
3
+ import type { Endpoint, InstanceId, InstancePingResult } from "@ccmsg/protocol";
4
+ import {
5
+ type InstanceEntry,
6
+ loadShared,
7
+ saveShared,
8
+ type SharedConfig,
9
+ } from "../instance/config.ts";
10
+ import { instanceIdentity } from "../instance/identity.ts";
11
+ import { alive, lockHolder } from "../instance/lock.ts";
12
+ import { type Env, type InstancePaths, resolvePaths } from "../instance/paths.ts";
13
+ import { prepareSocketDir } from "../instance/socket.ts";
14
+ import { connect, greetAsUser } from "./control.ts";
15
+ import { CommandError } from "./link.ts";
16
+
17
+ /** What a config home has to be for an instance to answer for it.
18
+ *
19
+ * `settings.json` is the harness's own file, so its presence is what says the
20
+ * directory is a config home rather than any directory somebody typed. Checked
21
+ * where a directory is named — `add` and `run` — rather than at every use, so
22
+ * the mistake is caught when it is made. */
23
+ export function configHome(dir: string): string {
24
+ const path = isAbsolute(dir) ? dir : resolve(dir);
25
+ if (!existsSync(join(path, "settings.json"))) {
26
+ throw new CommandError(
27
+ "not_found",
28
+ `${path} は Claude Code の config home ではありません (settings.json がありません)`,
29
+ );
30
+ }
31
+ return path;
32
+ }
33
+
34
+ /** One row of `daemon list`: which config home, and whether anything answers
35
+ * for it right now. */
36
+ export interface InstanceRow {
37
+ readonly id: InstanceId;
38
+ readonly dir: string;
39
+ readonly running: boolean;
40
+ readonly pid?: number;
41
+ }
42
+
43
+ /** One row of `daemon status`: the list's row, plus what the instance itself
44
+ * says when there is one to ask. */
45
+ export interface StatusRow extends InstanceRow {
46
+ readonly version?: string;
47
+ readonly network?: InstancePingResult["network"];
48
+ /** The other instances this one names, each with where it is dialled: the id
49
+ * says which instance and the endpoint says how to reach it, and neither
50
+ * follows from the other (DR-0001 §2.1). */
51
+ readonly peers?: readonly { readonly id: InstanceId; readonly endpoint: Endpoint }[];
52
+ }
53
+
54
+ /** Everything one command needs to reach one config home. */
55
+ export interface Target {
56
+ readonly dir: string;
57
+ readonly paths: InstancePaths;
58
+ }
59
+
60
+ export function targetFor(env: Env, dir: string): Target {
61
+ return { dir, paths: resolvePaths({ ...env, CLAUDE_CONFIG_DIR: dir }) };
62
+ }
63
+
64
+ /** The config homes the shared file lists, in the order it lists them. */
65
+ export function registered(env: Env): Target[] {
66
+ const paths = resolvePaths(env);
67
+ return loadShared(paths.configFile).instances.map((entry) => targetFor(env, entry.dir));
68
+ }
69
+
70
+ /** Add a config home to the shared file. The settings it will run with are the
71
+ * defaults until somebody edits its entry, so the entry starts empty. */
72
+ export function add(env: Env, dir: string): InstanceRow {
73
+ const home = configHome(dir);
74
+ const file = resolvePaths(env).configFile;
75
+ const shared = loadShared(file);
76
+ if (shared.instances.some((entry) => entry.dir === home)) {
77
+ throw new CommandError("file_exists", `${home} は既に登録されています`);
78
+ }
79
+ const entry: InstanceEntry = { dir: home, settings: {} };
80
+ saveShared(file, { ...shared, instances: [...shared.instances, entry] });
81
+ const target = targetFor(env, home);
82
+ // The id is made here rather than at the first start, so that what `add`
83
+ // prints is what the instance will answer to and so that a person can write
84
+ // the id into a peer's config before anything has run (DR-0001 §2.1).
85
+ instanceIdentity(target.paths.instanceIdFile);
86
+ return rowFor(target);
87
+ }
88
+
89
+ /** Take a config home off the list.
90
+ *
91
+ * The instance it names is left alone: what this changes is what the supervisor
92
+ * starts and what `--all` reaches, and an instance already serving a session is
93
+ * not something a list edit should take away from it. `daemon stop` is how one
94
+ * is stopped, and saying so is the point of keeping the two apart. */
95
+ export function remove(env: Env, dir: string): { dir: string; removed: boolean } {
96
+ const home = isAbsolute(dir) ? dir : resolve(dir);
97
+ const file = resolvePaths(env).configFile;
98
+ const shared: SharedConfig = loadShared(file);
99
+ const kept = shared.instances.filter((entry) => entry.dir !== home);
100
+ if (kept.length === shared.instances.length) {
101
+ throw new CommandError("not_found", `${home} は登録されていません`);
102
+ }
103
+ saveShared(file, { ...shared, instances: kept });
104
+ return { dir: home, removed: true };
105
+ }
106
+
107
+ /** What an instance is called, whether or not it is running.
108
+ *
109
+ * Read from the state directory rather than asked, so a stopped instance still
110
+ * has the name the running one answers to — and written there if it is not
111
+ * there yet, which is what makes this total for a config home that has been
112
+ * registered but never started (DR-0001 §2.1). */
113
+ export function idOf(target: Target): InstanceId {
114
+ return instanceIdentity(target.paths.instanceIdFile);
115
+ }
116
+
117
+ export function rowFor(target: Target): InstanceRow {
118
+ const pid = lockHolder(target.paths.lockFile);
119
+ const running = pid !== undefined && alive(pid);
120
+ return {
121
+ id: idOf(target),
122
+ dir: target.dir,
123
+ running,
124
+ ...(running ? { pid } : {}),
125
+ };
126
+ }
127
+
128
+ export function list(env: Env): InstanceRow[] {
129
+ return registered(env).map((target) => rowFor(target));
130
+ }
131
+
132
+ /** Ask one instance how it is. A config home with nothing behind it answers the
133
+ * list's row and nothing more: not running is a state, not a failure. */
134
+ export async function status(target: Target): Promise<StatusRow> {
135
+ const row = rowFor(target);
136
+ const conn = await connect(target.paths.socket);
137
+ if (conn === undefined) return row;
138
+ try {
139
+ const greeting = await greetAsUser(conn);
140
+ if (greeting["ok"] !== true) return row;
141
+ const peers =
142
+ (greeting["instances"] as { id: InstanceId; endpoint: Endpoint }[] | undefined) ?? [];
143
+ const answer = await conn.ask({ op: "instance_ping" });
144
+ if (answer["ok"] !== true) return row;
145
+ const ping = answer as unknown as InstancePingResult;
146
+ return {
147
+ ...row,
148
+ id: ping.instance,
149
+ running: true,
150
+ pid: ping.pid,
151
+ version: ping.version,
152
+ network: ping.network,
153
+ peers: peers
154
+ .filter((one) => one.id !== ping.instance)
155
+ .map((one) => ({ id: one.id, endpoint: one.endpoint })),
156
+ };
157
+ } finally {
158
+ conn.close();
159
+ }
160
+ }
161
+
162
+ /** Ask one instance to stop, over its own socket.
163
+ *
164
+ * The contract's op rather than a signal, so the request goes through the same
165
+ * authorization every other op does and the caller is told it was accepted
166
+ * before the process goes down (§8.5). */
167
+ export async function stop(target: Target): Promise<{ dir: string; stopped: boolean }> {
168
+ const conn = await connect(target.paths.socket);
169
+ if (conn === undefined) {
170
+ throw new CommandError("instance_unreachable", `${target.dir} の instance は動いていません`);
171
+ }
172
+ try {
173
+ const greeting = await greetAsUser(conn);
174
+ if (greeting["ok"] !== true) {
175
+ throw new CommandError("forbidden", `hello が拒否されました: ${JSON.stringify(greeting)}`);
176
+ }
177
+ const answer = await conn.ask({ op: "instance_shutdown" });
178
+ if (answer["ok"] !== true) {
179
+ throw new CommandError("internal_error", `停止を拒否されました: ${JSON.stringify(answer)}`);
180
+ }
181
+ return { dir: target.dir, stopped: true };
182
+ } finally {
183
+ conn.close();
184
+ }
185
+ }
186
+
187
+ /** How this process was started, so a child can be started the same way.
188
+ *
189
+ * The interpreter and the entry script rather than a name on `PATH`: a
190
+ * supervisor that resolved `ccmsg` again could start a different build from the
191
+ * one that spawned it, and which build is running is exactly what a person
192
+ * chasing a stale instance is trying to find out. */
193
+ export const ENTRY = new URL("../cli.ts", import.meta.url).pathname;
194
+
195
+ export interface Child {
196
+ readonly pid: number;
197
+ readonly exited: Promise<number>;
198
+ kill(signal?: NodeJS.Signals): void;
199
+ }
200
+
201
+ /** How a child instance is started. Injected so a test drives the supervisor
202
+ * without spawning one. */
203
+ export type SpawnInstance = (dir: string, env: Env) => Child;
204
+
205
+ export const spawnInstance: SpawnInstance = (dir, env) => {
206
+ const proc = Bun.spawn([process.execPath, ENTRY, "daemon", "run", dir], {
207
+ stdio: ["ignore", "ignore", "ignore"],
208
+ env: { ...env, CLAUDE_CONFIG_DIR: dir } as Record<string, string>,
209
+ });
210
+ return {
211
+ pid: proc.pid,
212
+ exited: proc.exited,
213
+ kill: (signal) => {
214
+ proc.kill(signal ?? "SIGTERM");
215
+ },
216
+ };
217
+ };
218
+
219
+ /** How long a start waits for the instance it spawned to be reachable before
220
+ * reporting what it saw.
221
+ *
222
+ * A deadline rather than an interval: what is waited on is the socket appearing
223
+ * or the child exiting, both of which are events, and this only bounds how long
224
+ * a start that does neither may hold the caller. */
225
+ export const START_TIMEOUT_MS = 10_000;
226
+
227
+ /** The directories an instance will fill, made before the child that fills
228
+ * them: a watch cannot report a change in a directory that does not exist. */
229
+ export function prepareFor(target: Target): void {
230
+ mkdirSync(target.paths.stateDir, { recursive: true });
231
+ prepareSocketDir(target.paths);
232
+ }
233
+
234
+ /** Wait for the stable socket to appear, on the directory's own change
235
+ * notifications rather than on a clock.
236
+ *
237
+ * The check comes first and again after the watch is up, because the socket may
238
+ * be published in the window between the two and a watch reports only what
239
+ * happens after it starts. */
240
+ export function awaitSocket(paths: InstancePaths, timeoutMs: number): Promise<void> {
241
+ return awaitEntry(paths, timeoutMs, () => existsSync(paths.socket));
242
+ }
243
+
244
+ /** Wait for the lock to be released, which is the last thing a departing
245
+ * instance does (§8.5). */
246
+ export function awaitGone(paths: InstancePaths, timeoutMs: number): Promise<void> {
247
+ return awaitEntry(paths, timeoutMs, () => {
248
+ const pid = lockHolder(paths.lockFile);
249
+ return pid === undefined || !alive(pid);
250
+ });
251
+ }
252
+
253
+ function awaitEntry(paths: InstancePaths, timeoutMs: number, ready: () => boolean): Promise<void> {
254
+ if (ready()) return Promise.resolve();
255
+ return new Promise((resolve, reject) => {
256
+ const watchers: { close(): void }[] = [];
257
+ let timer: ReturnType<typeof setTimeout> | undefined;
258
+ const done = (cause?: Error): void => {
259
+ clearTimeout(timer);
260
+ for (const watcher of watchers) watcher.close();
261
+ if (cause === undefined) resolve();
262
+ else reject(cause);
263
+ };
264
+ const look = (): void => {
265
+ if (ready()) done();
266
+ };
267
+ for (const dir of new Set([paths.socketDir, paths.stateDir])) {
268
+ try {
269
+ watchers.push(watch(dir, look));
270
+ } catch {
271
+ // The directory is not there yet; the other watch, or the deadline,
272
+ // is what this run has.
273
+ }
274
+ }
275
+ timer = setTimeout(() => {
276
+ done(
277
+ new CommandError(
278
+ "internal_error",
279
+ `${basename(paths.configHome)} の instance を ${String(timeoutMs)}ms 待ちましたが応答がありません`,
280
+ ),
281
+ );
282
+ }, timeoutMs);
283
+ look();
284
+ });
285
+ }