@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
package/src/cli.ts ADDED
@@ -0,0 +1,1074 @@
1
+ #!/usr/bin/env bun
2
+ import { type MessageSendArgs, type NotifySendArgs, PROTOCOL_VERSION } from "@ccmsg/protocol";
3
+ import {
4
+ add as addToConfig,
5
+ ask,
6
+ CommandError,
7
+ configHome,
8
+ connect,
9
+ expectedInstances,
10
+ follow,
11
+ idOf,
12
+ labelled,
13
+ list as listInstances,
14
+ reachable,
15
+ registered,
16
+ remove as removeFromConfig,
17
+ rowFor,
18
+ snapshots,
19
+ type SuperviseOp,
20
+ Supervisor,
21
+ tailOf,
22
+ type Target,
23
+ targetFor,
24
+ } from "./daemon/index.ts";
25
+ import { join } from "node:path";
26
+ import { hookEvent, type StatedMeta, statedMeta } from "./greeting/index.ts";
27
+ import { isRunning, resolveConfigHome, resolvePaths, start } from "./instance/index.ts";
28
+ import {
29
+ AGENTS,
30
+ install,
31
+ type Outcome,
32
+ status as pluginStatus,
33
+ uninstall,
34
+ } from "./plugin/index.ts";
35
+ import { type Run, runCommand, serviceFor } from "./service/index.ts";
36
+ import { VERSION } from "./version.ts";
37
+
38
+ /** Where a hook event is read from. Named for the same reason a speech binary
39
+ * is: a test drives the two commands a harness fires without a standard input
40
+ * of its own to write to. */
41
+ export type Read = () => Promise<string>;
42
+
43
+ /** The system speech binary. Absolute on purpose: a `say` shim earlier on PATH
44
+ * is what delegates here, so resolving through PATH again would re-enter the
45
+ * shim. */
46
+ const SYSTEM_SAY = "/usr/bin/say";
47
+
48
+ /** What every command may be given, and what every command reads from.
49
+ *
50
+ * `-h` / `--help` is the one option no level defines for itself: asking a
51
+ * command what it does is the same question at every level, and answering it
52
+ * where the tree is walked rather than inside each command is what keeps the
53
+ * answer the same. */
54
+ const GLOBAL_OPTIONS: readonly Doc[] = [["-h, --help", "そのレベルのヘルプを表示する"]];
55
+
56
+ const GLOBAL_ENV: readonly Doc[] = [
57
+ ["CLAUDE_CONFIG_DIR", "この CLI が話す instance の config home (既定は ~/.claude)"],
58
+ ["CCMSG_CONFIG_DIR", "共通 config の置き場 (既定は XDG_CONFIG_HOME/ccmsg)"],
59
+ ["CCMSG_STATE_DIR", "state・socket・pid・ログの置き場 (既定は XDG_STATE_HOME/ccmsg)"],
60
+ ];
61
+
62
+ /** A name and what it is, as the help prints it. */
63
+ type Doc = readonly [string, string];
64
+
65
+ /** One command in the tree.
66
+ *
67
+ * `run` answers with what the command found, and printing it is not its
68
+ * business: every command that is not `--help` answers in JSON, so there is one
69
+ * place that writes it and no command that can forget to. */
70
+ interface Command {
71
+ readonly name: string;
72
+ readonly summary: string;
73
+ readonly usage?: string;
74
+ readonly options?: readonly Doc[];
75
+ readonly env?: readonly Doc[];
76
+ readonly children?: readonly Command[];
77
+ readonly run?: (args: readonly string[]) => Promise<unknown>;
78
+ /** Whether running it with nothing after it is a command rather than a
79
+ * question. A level that leads somewhere answers "no arguments" with the help
80
+ * — there is nothing else it could mean — and so does a command whose
81
+ * arguments are required. This marks the rest: `daemon list` and
82
+ * `service status` take nothing, and printing their help instead of their
83
+ * answer would make them unreachable. */
84
+ readonly bare?: boolean;
85
+ /** A command that takes its arguments over rather than parsing them, which
86
+ * is what `say` is: its arguments belong to another program. */
87
+ readonly raw?: (args: readonly string[]) => Promise<number>;
88
+ }
89
+
90
+ /** The whole CLI, as the tree the help prints and the dispatch walks. */
91
+ const ROOT: Command = {
92
+ name: "ccmsg",
93
+ summary: "config home ごとの instance と、セッションからの一行",
94
+ usage: "ccmsg <command> [subcommand] [options] [--] [args...]",
95
+ children: [
96
+ {
97
+ name: "daemon",
98
+ summary: "instance の起動・停止・状態",
99
+ usage: "ccmsg daemon <subcommand> [options]",
100
+ options: [["--all", "登録されている instance すべてを対象にする"]],
101
+ children: [
102
+ {
103
+ name: "run",
104
+ summary: "この config home の instance を foreground で起動する (監督者の管理外)",
105
+ usage: "ccmsg daemon run [dir]",
106
+ bare: true,
107
+ run: (args) => runInstance(args[0]),
108
+ },
109
+ {
110
+ name: "supervise",
111
+ summary: "共通 config の instance を子プロセスとして起動し、落ちたら上げる",
112
+ usage: "ccmsg daemon supervise",
113
+ bare: true,
114
+ run: () => supervise(),
115
+ },
116
+ {
117
+ name: "add",
118
+ summary: "共通 config の instances[] に足し、監督者が居れば起こさせる",
119
+ usage: "ccmsg daemon add <dir>",
120
+ run: (args) => added(args[0]),
121
+ },
122
+ {
123
+ name: "remove",
124
+ summary: "instances[] から外す (監督者は以後見ないが、子は止めない)",
125
+ usage: "ccmsg daemon remove <dir>",
126
+ run: (args) => removed(args[0]),
127
+ },
128
+ {
129
+ name: "list",
130
+ summary: "登録されている config home と、動いているかを並べる",
131
+ usage: "ccmsg daemon list",
132
+ bare: true,
133
+ run: () => Promise.resolve(listInstances(process.env)),
134
+ },
135
+ {
136
+ name: "start",
137
+ summary: "監督者に、この config home の子を起こさせる",
138
+ usage: "ccmsg daemon start <dir> | --all",
139
+ run: (args) => supervised("supervise_start", args),
140
+ },
141
+ {
142
+ name: "stop",
143
+ summary: "監督者に、子を止めさせる (instance_shutdown、以後は上げ直さない)",
144
+ usage: "ccmsg daemon stop <dir> | --all",
145
+ run: (args) => supervised("supervise_stop", args),
146
+ },
147
+ {
148
+ name: "restart",
149
+ summary: "監督者に、止めてから起こし直させる",
150
+ usage: "ccmsg daemon restart <dir> | --all",
151
+ run: (args) => supervised("supervise_restart", args),
152
+ },
153
+ {
154
+ name: "status",
155
+ summary: "監督者が各子に instance_ping して version・network・peers を答える",
156
+ usage: "ccmsg daemon status [dir] | --all",
157
+ bare: true,
158
+ run: (args) => supervised("supervise_status", args, true),
159
+ },
160
+ {
161
+ name: "log",
162
+ summary: "instance の daemon.log を出す (--all は行に id を足して多重化)",
163
+ usage: "ccmsg daemon log [dir] | --all [--follow]",
164
+ options: [["--follow", "書き足される行を待ち続ける (Ctrl-C で終わり)"]],
165
+ bare: true,
166
+ run: (args) => daemonLog(args),
167
+ },
168
+ ],
169
+ },
170
+ {
171
+ name: "service",
172
+ summary: "監督者 (ccmsg daemon supervise) を launchd / systemd に登録する",
173
+ usage: "ccmsg service <subcommand>",
174
+ children: [
175
+ {
176
+ name: "register",
177
+ summary: "監督者をこのホストの init system に登録する",
178
+ usage: "ccmsg service register",
179
+ bare: true,
180
+ run: () => serviceOp("register"),
181
+ },
182
+ {
183
+ name: "unregister",
184
+ summary: "登録を外し、置いた定義ファイルを消す",
185
+ usage: "ccmsg service unregister",
186
+ bare: true,
187
+ run: () => serviceOp("unregister"),
188
+ },
189
+ {
190
+ name: "start",
191
+ summary: "監督者を起動する",
192
+ usage: "ccmsg service start",
193
+ bare: true,
194
+ run: () => serviceOp("start"),
195
+ },
196
+ {
197
+ name: "stop",
198
+ summary: "監督者を停止する",
199
+ usage: "ccmsg service stop",
200
+ bare: true,
201
+ run: () => serviceOp("stop"),
202
+ },
203
+ {
204
+ name: "status",
205
+ summary: "登録の有無・監督者の pid・init system 側の状態・見ている instance",
206
+ usage: "ccmsg service status",
207
+ bare: true,
208
+ run: () => serviceOp("status"),
209
+ },
210
+ {
211
+ name: "log",
212
+ summary: "監督者と init system 側のログを出す",
213
+ usage: "ccmsg service log [--follow]",
214
+ options: [["--follow", "書き足される行を待ち続ける (Ctrl-C で終わり)"]],
215
+ bare: true,
216
+ run: (args) => serviceLog(args),
217
+ },
218
+ ],
219
+ },
220
+ {
221
+ name: "plugin",
222
+ summary: "エージェントへ ccmsg のプラグインを入れる",
223
+ usage: "ccmsg plugin <subcommand> [<agent>]",
224
+ env: [["", `エージェント: ${AGENTS.join(", ")}`]],
225
+ children: [
226
+ {
227
+ name: "install",
228
+ summary: "そのエージェントに ccmsg のプラグインを入れる",
229
+ usage: "ccmsg plugin install <agent>",
230
+ options: [["", "開いているセッションには /reload-plugins で反映される"]],
231
+ run: (args) => plugin("install", args[0]),
232
+ },
233
+ {
234
+ name: "status",
235
+ summary: "入れたものと今の状態を見比べる",
236
+ usage: "ccmsg plugin status [<agent>]",
237
+ run: (args) => plugin("status", args[0]),
238
+ },
239
+ {
240
+ name: "uninstall",
241
+ summary: "入れたものだけを元に戻す",
242
+ usage: "ccmsg plugin uninstall <agent>",
243
+ run: (args) => plugin("uninstall", args[0]),
244
+ },
245
+ ],
246
+ },
247
+ {
248
+ name: "peers",
249
+ summary: "instance が知っているセッションを並べる (相手の sid を探す)",
250
+ usage: "ccmsg peers [--all]",
251
+ bare: true,
252
+ options: [
253
+ ["--all", "mesh 越しの instance が言っている分も含める"],
254
+ ["--json", "JSON で答える (既定、この CLI は常に JSON で答える)"],
255
+ ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
256
+ ],
257
+ env: sessionEnv(),
258
+ run: (args) => peers(args),
259
+ },
260
+ {
261
+ name: "agents",
262
+ summary: "harness 自身が見ているセッションを並べる (繋いでいないものも含む)",
263
+ usage: "ccmsg agents [--all]",
264
+ bare: true,
265
+ options: [
266
+ ["--all", "mesh 越しの instance が言っている分も含める"],
267
+ ["--json", "JSON で答える (既定、この CLI は常に JSON で答える)"],
268
+ ],
269
+ run: (args) => agents(args),
270
+ },
271
+ {
272
+ name: "post",
273
+ summary: "別のセッションへメッセージを送る",
274
+ usage: "ccmsg post <sid> <text> [--sid <自分の sid>]",
275
+ options: [["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"]],
276
+ env: sessionEnv(),
277
+ run: (args) => post(args),
278
+ },
279
+ {
280
+ name: "reply",
281
+ summary: "受け取ったメッセージに返信する",
282
+ usage: "ccmsg reply <mid> <text> [--to <相手の sid>]",
283
+ options: [
284
+ ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
285
+ ["--to <sid>", "宛先セッション (封筒の ccmsg-from)。省略すると人への返信"],
286
+ ],
287
+ env: sessionEnv(),
288
+ run: (args) => reply(args),
289
+ },
290
+ {
291
+ name: "notify",
292
+ summary: "見ている人へ一行知らせる (保持されない、返事も来ない)",
293
+ usage: "ccmsg notify <text> [--about <sid>]",
294
+ options: [
295
+ ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
296
+ ["--about <sid>", "知らせるセッション (既定は自分)"],
297
+ ],
298
+ env: sessionEnv(),
299
+ run: (args) => notify(args),
300
+ },
301
+ {
302
+ name: "stopping",
303
+ summary: "これから終わると instance に伝える (以後は Paused 扱い)",
304
+ usage: "ccmsg stopping [--reason <text>] [--hook]",
305
+ bare: true,
306
+ options: [
307
+ ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
308
+ ["--reason <text>", "終わる理由 (表示用、任意)"],
309
+ ["--hook", "harness の hook イベント JSON を標準入力から読む"],
310
+ ],
311
+ env: sessionEnv(),
312
+ run: (args) => stopping(args),
313
+ },
314
+ {
315
+ name: "hello",
316
+ summary: "自分がどこで動いているかを instance に名乗る",
317
+ usage: "ccmsg hello [--cwd <path>] [--repo <name>] ... [--hook]",
318
+ bare: true,
319
+ options: [
320
+ ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
321
+ ["--cwd <path>", "作業ディレクトリ"],
322
+ ["--repo <name>", "リポジトリの表示名"],
323
+ ["--ws <name>", "ワークスペース名"],
324
+ ["--repo-root <path>", "リポジトリの入れ物"],
325
+ ["--branch <name>", "チェックアウト中のブランチ"],
326
+ ["--transcript-path <path>", "会話の記録"],
327
+ ["--title <text>", "セッションの題"],
328
+ ["--hook", "harness の hook イベント JSON を標準入力から読む"],
329
+ ],
330
+ env: sessionEnv(),
331
+ run: (args) => hello(args),
332
+ },
333
+ {
334
+ name: "say",
335
+ summary: `${SYSTEM_SAY} で発声し、どのセッションが喋ったかを知らせる`,
336
+ usage: "ccmsg say [say-options] [text...]",
337
+ options: [["", `引数は ${SYSTEM_SAY} へそのまま渡す (単独の --help だけが例外)`]],
338
+ env: [
339
+ ["CLAUDE_CODE_SESSION_ID", "喋ったセッションの名乗り"],
340
+ ["CCMSG_SAY_BIN", `発声に使うバイナリ (既定は ${SYSTEM_SAY})`],
341
+ ],
342
+ raw: (args) => say(args),
343
+ },
344
+ ],
345
+ };
346
+
347
+ function sessionEnv(): readonly Doc[] {
348
+ return [["CLAUDE_CODE_SESSION_ID", "自分のセッション ID"]];
349
+ }
350
+
351
+ /** Walk the tree, and answer at the level the arguments reach.
352
+ *
353
+ * No arguments is the help at every level, which is why the walk checks it
354
+ * before it checks anything else: a command that was named without being told
355
+ * what to do has nothing to answer but what it can be told. */
356
+ export async function main(argv: readonly string[]): Promise<number> {
357
+ const path: Command[] = [ROOT];
358
+ let rest = argv;
359
+ for (;;) {
360
+ const at = path[path.length - 1] as Command;
361
+ if (at.raw !== undefined) {
362
+ return await at.raw(rest);
363
+ }
364
+ const asked = rest[0] === "-h" || rest[0] === "--help";
365
+ if (asked || (rest.length === 0 && at.bare !== true)) {
366
+ process.stdout.write(help(path));
367
+ return asked || at.run === undefined ? 0 : 2;
368
+ }
369
+ const child = at.children?.find((one) => one.name === rest[0]);
370
+ if (child !== undefined) {
371
+ path.push(child);
372
+ rest = rest.slice(1);
373
+ continue;
374
+ }
375
+ if (at.run === undefined) {
376
+ const word = rest[0] as string;
377
+ process.stderr.write(`${help(path)}\n`);
378
+ return report(new CommandError("bad_request", `知らないサブコマンドです: ${word}`));
379
+ }
380
+ try {
381
+ emit(await at.run(rest));
382
+ return 0;
383
+ } catch (cause) {
384
+ return report(cause);
385
+ }
386
+ }
387
+ }
388
+
389
+ /** Everything a command answers, as one JSON document. */
390
+ function emit(value: unknown): void {
391
+ if (value === undefined) return;
392
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
393
+ }
394
+
395
+ /** Everything a command refuses, in the contract's error shape. */
396
+ function report(cause: unknown): number {
397
+ const error =
398
+ cause instanceof CommandError
399
+ ? {
400
+ code: cause.code,
401
+ msg: cause.message,
402
+ ...(cause.detail === undefined ? {} : { detail: cause.detail }),
403
+ }
404
+ : { code: "internal_error", msg: String(cause) };
405
+ process.stderr.write(`${JSON.stringify({ error }, null, 2)}\n`);
406
+ return 1;
407
+ }
408
+
409
+ /** The help for one level: its subcommands, its own options, the global ones,
410
+ * and the environment either it or the whole CLI reads. */
411
+ function help(path: readonly Command[]): string {
412
+ const at = path[path.length - 1] as Command;
413
+ const name = path.map((one) => one.name).join(" ");
414
+ const lines = [`${name} — ${at.summary}`, ""];
415
+ lines.push("使い方:", ` ${at.usage ?? name}`, "");
416
+ if (at.children !== undefined) {
417
+ lines.push("サブコマンド:");
418
+ const width = Math.max(...at.children.map((one) => one.name.length));
419
+ for (const child of at.children) {
420
+ lines.push(` ${child.name.padEnd(width)} ${child.summary}`);
421
+ }
422
+ lines.push("");
423
+ }
424
+ section(lines, "このレベルのオプション:", at.options);
425
+ section(lines, "グローバルオプション:", GLOBAL_OPTIONS);
426
+ section(lines, "環境変数:", [...(at.env ?? []), ...GLOBAL_ENV]);
427
+ return `${lines.join("\n").trimEnd()}\n`;
428
+ }
429
+
430
+ function section(lines: string[], title: string, docs: readonly Doc[] | undefined): void {
431
+ if (docs === undefined || docs.length === 0) return;
432
+ lines.push(title);
433
+ const width = Math.max(...docs.map(([label]) => label.length));
434
+ for (const [label, text] of docs) lines.push(` ${label.padEnd(width)} ${text}`);
435
+ lines.push("");
436
+ }
437
+
438
+ /** `ccmsg daemon run [dir]`: this config home's instance, in the foreground. */
439
+ async function runInstance(dir: string | undefined): Promise<unknown> {
440
+ const home = configHome(dir ?? resolveConfigHome());
441
+ const outcome = await start({ env: { ...process.env, CLAUDE_CONFIG_DIR: home } });
442
+ if (!isRunning(outcome)) {
443
+ throw new CommandError(
444
+ "file_exists",
445
+ `${home} の instance は既に動いています (pid ${String(outcome.pid)})`,
446
+ );
447
+ }
448
+ const instance = outcome;
449
+ // A signal is a request to leave, and leaving is the ordered shutdown of
450
+ // §8.5 — the same one `instance_shutdown` runs, so a client sees the same
451
+ // departure either way. The listeners are removed once it has run, because a
452
+ // signal listener keeps the event loop alive and the process would sit at an
453
+ // empty loop instead of exiting.
454
+ const signals = ["SIGINT", "SIGTERM"] as const;
455
+ const asked = (): void => {
456
+ void instance.stop();
457
+ };
458
+ for (const signal of signals) process.on(signal, asked);
459
+ await instance.whenStopped();
460
+ for (const signal of signals) process.off(signal, asked);
461
+ return { dir: home, instance: instance.self, ran: true };
462
+ }
463
+
464
+ /** `ccmsg daemon supervise`: the instances the shared config lists, kept up. */
465
+ async function supervise(): Promise<unknown> {
466
+ const supervisor = new Supervisor();
467
+ const signals = ["SIGINT", "SIGTERM"] as const;
468
+ const asked = (): void => {
469
+ void supervisor.stop();
470
+ };
471
+ for (const signal of signals) process.on(signal, asked);
472
+ await supervisor.run();
473
+ for (const signal of signals) process.off(signal, asked);
474
+ return { supervised: supervisor.targets.map((target) => target.dir) };
475
+ }
476
+
477
+ /** `ccmsg daemon add <dir>`: write it down, and have it started.
478
+ *
479
+ * The file first and the supervisor second, because the file is what survives:
480
+ * a host with no supervisor running still gets the config home added, and the
481
+ * next supervisor starts it. Told rather than left to be discovered, because
482
+ * the supervisor reads the list once (DV-Q8) and would otherwise not know
483
+ * until it is restarted. */
484
+ async function added(dir: string | undefined): Promise<unknown> {
485
+ if (dir === undefined) throw new CommandError("invalid_args", "使い方: ccmsg daemon add <dir>");
486
+ const row = addToConfig(process.env, dir);
487
+ if (!(await reachable())) return { ...row, supervised: false };
488
+ const started = (await ask({ op: "supervise_add", dir: row.dir })) as Record<string, unknown>;
489
+ return { ...started, supervised: true };
490
+ }
491
+
492
+ /** `ccmsg daemon remove <dir>`: take it off the list, and stop looking after it.
493
+ *
494
+ * The instance itself is left alone: a list edit is not a shutdown, and a
495
+ * session already talking to that instance keeps it. `daemon stop` is how one
496
+ * is stopped, and keeping the two apart is what makes that true. */
497
+ async function removed(dir: string | undefined): Promise<unknown> {
498
+ if (dir === undefined) {
499
+ throw new CommandError("invalid_args", "使い方: ccmsg daemon remove <dir>");
500
+ }
501
+ const row = removeFromConfig(process.env, dir);
502
+ if (!(await reachable())) return { ...row, supervised: false };
503
+ await ask({ op: "supervise_remove", dir: row.dir });
504
+ return { ...row, supervised: false };
505
+ }
506
+
507
+ /** The four commands that are requests to the supervisor rather than things
508
+ * this process does.
509
+ *
510
+ * They are its business because it is the one that holds the children: a second
511
+ * route that started an instance behind the supervisor's back would produce an
512
+ * instance nothing restarts and nothing knows about. With no supervisor there
513
+ * is nobody to ask, which is what the caller is told. */
514
+ async function supervised(
515
+ op: SuperviseOp,
516
+ args: readonly string[],
517
+ hereByDefault = false,
518
+ ): Promise<unknown> {
519
+ const parsed = options(args, [], ["all"]);
520
+ const all = parsed.flags.has("all");
521
+ const named = parsed.rest[0];
522
+ if (all && named !== undefined) {
523
+ throw new CommandError("invalid_args", "--all と dir は同時に指定できません");
524
+ }
525
+ if (all) return await ask({ op, all: true });
526
+ const dir = named ?? (hereByDefault ? resolveConfigHome() : undefined);
527
+ if (dir === undefined) throw new CommandError("invalid_args", "dir か --all が要ります");
528
+ return await ask({ op, dir });
529
+ }
530
+
531
+ /** `ccmsg service <what>`: the supervisor's registration with the host. */
532
+ async function serviceOp(
533
+ what: "register" | "unregister" | "start" | "stop" | "status",
534
+ run: Run = runCommand,
535
+ ): Promise<unknown> {
536
+ const service = serviceFor();
537
+ if (what === "unregister") return { kind: service.kind, ...(await service.unregister(run)) };
538
+ const state =
539
+ what === "register"
540
+ ? await service.register(run)
541
+ : what === "start"
542
+ ? await service.start(run)
543
+ : what === "stop"
544
+ ? await service.stop(run)
545
+ : await service.state(run);
546
+ if (what !== "status") return { kind: service.kind, unit: service.unitFile, ...state };
547
+ return {
548
+ kind: service.kind,
549
+ unit: service.unitFile,
550
+ ...state,
551
+ instances: registered(process.env).map((target) => {
552
+ const row = rowFor(target);
553
+ return { id: row.id, dir: row.dir, running: row.running };
554
+ }),
555
+ };
556
+ }
557
+
558
+ /** `ccmsg daemon log`: what one instance wrote down, or what all of them did.
559
+ *
560
+ * JSON lines rather than one document, because a log is a stream and `--follow`
561
+ * has no end to close a document at. Over `--all` each line carries the
562
+ * instance it came from, which is the whole of what multiplexing needs: the
563
+ * lines are already JSON objects, so the label goes beside their fields. */
564
+ async function daemonLog(args: readonly string[]): Promise<undefined> {
565
+ const parsed = options(args, [], ["follow", "all"]);
566
+ const following = parsed.flags.has("follow");
567
+ // The label follows `--all` rather than how many config homes happen to be
568
+ // registered: a reader that asked for every instance's log gets the same
569
+ // shape whether the host runs one or five.
570
+ const many = parsed.flags.has("all");
571
+ const targets = many
572
+ ? registered(process.env)
573
+ : [targetFor(process.env, parsed.rest[0] ?? resolveConfigHome())];
574
+ const write = (target: Target, lines: readonly string[]): void => {
575
+ for (const line of lines) {
576
+ process.stdout.write(
577
+ `${many ? labelled(line, { id: idOf(target), dir: target.dir }) : line}\n`,
578
+ );
579
+ }
580
+ };
581
+ const followers: { close(): void }[] = [];
582
+ for (const target of targets) {
583
+ const file = join(target.paths.stateDir, "daemon.log");
584
+ const read = await tailOf(file);
585
+ write(target, read.lines);
586
+ if (following)
587
+ followers.push(follow(file, read.end, (lines: readonly string[]) => write(target, lines)));
588
+ }
589
+ if (following) await never(() => followers.forEach((one) => one.close()));
590
+ return undefined;
591
+ }
592
+
593
+ /** `ccmsg service log`: what the supervisor said, and what the init system says
594
+ * about the last time it ran. */
595
+ async function serviceLog(args: readonly string[], run: Run = runCommand): Promise<undefined> {
596
+ const parsed = options(args, [], ["follow"]);
597
+ const following = parsed.flags.has("follow");
598
+ const service = serviceFor();
599
+ // What the init system knows is one line at the top rather than a section:
600
+ // whether it is loaded, running and what it last exited with is the context
601
+ // the lines below are read in.
602
+ const state = await service.state(run);
603
+ process.stdout.write(`${JSON.stringify({ unit: service.unitFile, ...state })}\n`);
604
+ const source = service.logSource();
605
+ if (source.kind === "command") {
606
+ const command = following ? source.follow : source.show;
607
+ return await Bun.spawn(command, { stdout: "inherit", stderr: "inherit" }).exited.then(
608
+ () => undefined,
609
+ );
610
+ }
611
+ const read = await tailOf(source.file);
612
+ for (const line of read.lines) process.stdout.write(`${line}\n`);
613
+ if (!following) return undefined;
614
+ const follower = follow(source.file, read.end, (lines: readonly string[]) => {
615
+ for (const line of lines) process.stdout.write(`${line}\n`);
616
+ });
617
+ await never(() => {
618
+ follower.close();
619
+ });
620
+ return undefined;
621
+ }
622
+
623
+ /** Run until the terminal takes the command away, which is what `--follow`
624
+ * means: there is no last line to stop at, so what ends it is a signal. */
625
+ function never(release: () => void): Promise<void> {
626
+ return new Promise((resolve) => {
627
+ const leave = (): void => {
628
+ release();
629
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.off(signal, leave);
630
+ resolve();
631
+ };
632
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, leave);
633
+ });
634
+ }
635
+
636
+ /** `ccmsg peers`: the sessions this instance holds, and the ones it has lost.
637
+ *
638
+ * The command a session runs to find out who else is there: a `post` needs the
639
+ * other session's sid, and nothing else states one. It greets as the session it
640
+ * runs inside when it knows which that is, because one field of the answer is
641
+ * computed against the asker — `send_message` says whether the harness's own
642
+ * messaging reaches that peer, and there is nobody to compare against for a
643
+ * greeting that named no session. Without a sid it asks as a person, which is
644
+ * the same list minus that field. */
645
+ function peers(args: readonly string[]): Promise<unknown> {
646
+ const parsed = options(args, ["sid"], ["all", "json"]);
647
+ const sid = parsed.named.get("sid") ?? process.env["CLAUDE_CODE_SESSION_ID"];
648
+ return topic(
649
+ "peers",
650
+ parsed.flags.has("all"),
651
+ sid === undefined || sid === ""
652
+ ? { op: "hello", role: "user", protocol_version: PROTOCOL_VERSION }
653
+ : { op: "hello", role: "session", sid, protocol_version: PROTOCOL_VERSION, ...statedMeta() },
654
+ );
655
+ }
656
+
657
+ /** `ccmsg agents`: the harness's own view, which covers sessions that never
658
+ * connected here. Asked as a person, because that is who the topic is open to:
659
+ * it names processes and config homes rather than anything one session is a
660
+ * party to. */
661
+ function agents(args: readonly string[]): Promise<unknown> {
662
+ const parsed = options(args, [], ["all", "json"]);
663
+ return topic("agents", parsed.flags.has("all"), {
664
+ op: "hello",
665
+ role: "user",
666
+ protocol_version: PROTOCOL_VERSION,
667
+ });
668
+ }
669
+
670
+ /** Read the current value of a cluster topic and answer with it.
671
+ *
672
+ * One entry per instance, carrying the topic's payload exactly as the contract
673
+ * defines it: a whole value per instance is not something to merge into one
674
+ * list, since two instances' entries stand side by side and only the frame says
675
+ * whose is whose. */
676
+ async function topic(
677
+ name: "peers" | "agents",
678
+ all: boolean,
679
+ greeting: Record<string, unknown>,
680
+ ): Promise<unknown> {
681
+ const paths = resolvePaths();
682
+ const conn = await connect(paths.socket);
683
+ if (conn === undefined) {
684
+ throw new CommandError(
685
+ "instance_unreachable",
686
+ `${paths.socket} に繋がりません (instance は動いていません)`,
687
+ );
688
+ }
689
+ try {
690
+ const greeted = await conn.ask(greeting);
691
+ if (greeted["ok"] !== true) {
692
+ throw new CommandError("forbidden", `hello が拒否されました: ${JSON.stringify(greeted)}`);
693
+ }
694
+ return await snapshots(conn, name, expectedInstances(greeted, all));
695
+ } finally {
696
+ conn.close();
697
+ }
698
+ }
699
+
700
+ /** `ccmsg post <sid> <text>`: start a conversation with another session. */
701
+ function post(args: readonly string[]): Promise<unknown> {
702
+ const parsed = options(args, ["sid"]);
703
+ const [to, text] = parsed.rest;
704
+ if (to === undefined || text === undefined) {
705
+ throw new CommandError("invalid_args", "使い方: ccmsg post <sid> <text>");
706
+ }
707
+ return send(parsed.named.get("sid"), { to, text });
708
+ }
709
+
710
+ /** `ccmsg reply <mid> <text>`: answer a message, which is the line the contract
711
+ * writes into every message delivered over the harness's socket.
712
+ *
713
+ * `--to` is the recipient, and there is no second source for it: a `mid` names
714
+ * the instance that issued it and its number, so nothing in it says which
715
+ * session sent the message, and no op resolves one. The value is in the
716
+ * delivered envelope as `ccmsg-from`.
717
+ *
718
+ * Without `--to` the answer is for a person, because that is the one message
719
+ * whose reply line carries no addressee: `user` is not a sid and `message_send`
720
+ * addresses a sid, so there is nothing to send to. The contract leaves how such
721
+ * an answer arrives to the instance and names the route — it reaches them as a
722
+ * notification, which is `notify_send`. The caller runs the line either way and
723
+ * does not have to know which of the two it became. */
724
+ function reply(args: readonly string[]): Promise<unknown> {
725
+ const parsed = options(args, ["sid", "to"]);
726
+ const [mid, text] = parsed.rest;
727
+ if (mid === undefined || text === undefined) {
728
+ throw new CommandError("invalid_args", "使い方: ccmsg reply <mid> <text> [--to <相手の sid>]");
729
+ }
730
+ const named = parsed.named.get("sid");
731
+ const to = parsed.named.get("to");
732
+ if (to === undefined) return announce(named, { text });
733
+ return send(named, { to, text, reply_to: mid });
734
+ }
735
+
736
+ /** `ccmsg notify <text>`: a line for whoever is watching. Nothing is held and
737
+ * nothing is acknowledged, so there is no outcome to report beyond the op
738
+ * having been accepted. */
739
+ function notify(args: readonly string[]): Promise<unknown> {
740
+ const parsed = options(args, ["sid", "about"]);
741
+ const [text] = parsed.rest;
742
+ if (text === undefined) {
743
+ throw new CommandError("invalid_args", "使い方: ccmsg notify <text> [--about <sid>]");
744
+ }
745
+ const about = parsed.named.get("about");
746
+ return announce(parsed.named.get("sid"), {
747
+ text,
748
+ ...(about === undefined ? {} : { sid: about }),
749
+ });
750
+ }
751
+
752
+ /** `ccmsg stopping`: this session is about to go.
753
+ *
754
+ * What it buys is the difference between Paused and Disappeared (§5.2): the
755
+ * instance holds the declaration until the connection closes, and the entry it
756
+ * then writes carries the instant it was told. The connection closing is the
757
+ * second half of that, so the command says its piece and leaves — which is
758
+ * what a session-end hook does anyway.
759
+ *
760
+ * A session that says this and then carries on is not held to it: it stays
761
+ * connected and stays live, and the declaration is spent whenever it does go. */
762
+ export async function stopping(args: readonly string[], read?: Read): Promise<unknown> {
763
+ const parsed = options(args, ["sid", "reason"], ["hook"]);
764
+ const event = parsed.flags.has("hook") ? await hookEvent(read) : {};
765
+ const reason = parsed.named.get("reason") ?? event.reason;
766
+ // A hook is told things this process cannot work out for itself — where the
767
+ // transcript is, above all — so the departure says them rather than only
768
+ // what the working directory reveals.
769
+ return await call(
770
+ parsed.named.get("sid") ?? event.sid,
771
+ { op: "session_stopping", ...(reason === undefined ? {} : { reason }) },
772
+ stated(parsed.named, event),
773
+ );
774
+ }
775
+
776
+ /** `ccmsg hello`: say where this session is working.
777
+ *
778
+ * A greeting is how a session states its repository, workspace and branch, and
779
+ * those are what the instance shows it by — a session that has never said them
780
+ * is shown by its sid, which tells nobody which of a person's sessions it is.
781
+ * The connection is not held afterwards: the session's own client processes
782
+ * make their own, and this one exists to have said the words.
783
+ *
784
+ * Best effort throughout, like the record a `say` leaves. It is run from a
785
+ * session-start hook, where there is nothing a person asked for to fail: no
786
+ * instance running, no session id and a greeting that is turned away all leave
787
+ * the session working exactly as it was, so none of them is worth a line in
788
+ * front of somebody's first prompt. */
789
+ export async function hello(args: readonly string[], read?: Read): Promise<unknown> {
790
+ const parsed = options(
791
+ args,
792
+ ["sid", "cwd", "repo", "ws", "repo-root", "branch", "transcript-path", "title"],
793
+ ["hook"],
794
+ );
795
+ const event = parsed.flags.has("hook") ? await hookEvent(read) : {};
796
+ const sid = parsed.named.get("sid") ?? event.sid ?? process.env["CLAUDE_CODE_SESSION_ID"];
797
+ if (sid === undefined || sid === "") return { greeted: false, reason: "no_session_id" };
798
+ const meta = stated(parsed.named, event);
799
+ const paths = resolvePaths();
800
+ const conn = await connect(paths.socket);
801
+ if (conn === undefined) return { greeted: false, reason: "no_instance" };
802
+ try {
803
+ await conn.ask({
804
+ op: "hello",
805
+ role: "session",
806
+ sid,
807
+ protocol_version: PROTOCOL_VERSION,
808
+ ...meta,
809
+ });
810
+ return { greeted: true, sid };
811
+ } catch {
812
+ // The instance went away mid-greeting. Nothing was asked of it beyond
813
+ // being told, so there is nothing to retry and nothing to report.
814
+ return { greeted: false, reason: "no_instance" };
815
+ } finally {
816
+ conn.close();
817
+ }
818
+ }
819
+
820
+ /** What this process says about itself, as options over what it can work out.
821
+ *
822
+ * The derivation is the floor: whoever runs the command may know better —
823
+ * a hook is told the working directory and the transcript by the harness, and
824
+ * a caller may state any field outright — and what is stated wins over what is
825
+ * derived, field by field. */
826
+ function stated(
827
+ named: ReadonlyMap<string, string>,
828
+ event: { cwd?: string; transcript_path?: string },
829
+ ): StatedMeta {
830
+ const cwd = named.get("cwd") ?? event.cwd;
831
+ const transcript = named.get("transcript-path") ?? event.transcript_path;
832
+ const derived = statedMeta(cwd ?? process.cwd());
833
+ return {
834
+ ...derived,
835
+ ...only("repo", named.get("repo")),
836
+ ...only("ws", named.get("ws")),
837
+ ...only("repo_root", named.get("repo-root")),
838
+ ...only("branch", named.get("branch")),
839
+ ...only("title", named.get("title")),
840
+ ...only("transcript_path", transcript),
841
+ };
842
+ }
843
+
844
+ function only(field: keyof StatedMeta, value: string | undefined): StatedMeta {
845
+ return value === undefined || value === "" ? {} : { [field]: value };
846
+ }
847
+
848
+ /** `ccmsg plugin <what> <agent>`: what ccmsg installs into an agent, and what
849
+ * it takes back out.
850
+ *
851
+ * The agent is named rather than assumed: there will be more than one, and a
852
+ * command that guessed which one a person meant would be the command that
853
+ * writes into the wrong config home. */
854
+ async function plugin(
855
+ what: "install" | "status" | "uninstall",
856
+ agent: string | undefined,
857
+ ): Promise<unknown> {
858
+ if (agent !== undefined && agent !== "claude") {
859
+ throw new CommandError(
860
+ "invalid_args",
861
+ `${agent} 用のプラグインはまだありません (今あるのは ${AGENTS.join(", ")})`,
862
+ );
863
+ }
864
+ if (what !== "status" && agent === undefined) {
865
+ throw new CommandError(
866
+ "invalid_args",
867
+ `使い方: ccmsg plugin ${what} <agent> (今あるのは ${AGENTS.join(", ")})`,
868
+ );
869
+ }
870
+ const paths = resolvePaths();
871
+ const outcome: Outcome =
872
+ what === "install"
873
+ ? await install(paths, VERSION)
874
+ : what === "status"
875
+ ? await pluginStatus(paths)
876
+ : await uninstall(paths);
877
+ // A refused step is an error rather than an answer, so the command's exit
878
+ // code says what happened without the report having to be read. The report
879
+ // itself travels with it: what was done before the refusal is what the next
880
+ // attempt starts from.
881
+ if (!outcome.ok) {
882
+ const refused = outcome.refused;
883
+ throw new CommandError(
884
+ "internal_error",
885
+ refused === undefined
886
+ ? `plugin ${what} が完了しませんでした`
887
+ : `${refused.command.join(" ")}: ${refused.said === "" ? `終了コード ${refused.code}` : refused.said}`,
888
+ outcome,
889
+ );
890
+ }
891
+ return outcome;
892
+ }
893
+
894
+ /** One `message_send`. */
895
+ function send(named: string | undefined, args: MessageSendArgs): Promise<unknown> {
896
+ return call(named, { op: "message_send", ...args });
897
+ }
898
+
899
+ /** One `notify_send`. */
900
+ function announce(named: string | undefined, args: NotifySendArgs): Promise<unknown> {
901
+ return call(named, { op: "notify_send", ...args });
902
+ }
903
+
904
+ /** One op, spoken as the session this process runs inside.
905
+ *
906
+ * The greeting is `role: "session"` because that is what the caller is: the
907
+ * instance takes the sender and the subject from the connection rather than
908
+ * from the arguments, so a connection that greeted as anything else has nobody
909
+ * to answer and nothing to be about.
910
+ *
911
+ * It also says where the session is working, because that is what a message's
912
+ * recipient is shown as its sender: the label is built from the repository and
913
+ * workspace the sending connection greeted from, and a greeting that named
914
+ * neither leaves the recipient a sid to read. The words cost one `git` call and
915
+ * they are the difference between "ccmsg/main said this" and a line of hex. */
916
+ async function call(
917
+ named: string | undefined,
918
+ request: Record<string, unknown>,
919
+ meta: StatedMeta = statedMeta(),
920
+ ): Promise<unknown> {
921
+ const sid = named ?? process.env["CLAUDE_CODE_SESSION_ID"];
922
+ if (sid === undefined || sid === "") {
923
+ throw new CommandError(
924
+ "invalid_args",
925
+ "自分のセッション ID が分かりません (--sid か CLAUDE_CODE_SESSION_ID)",
926
+ );
927
+ }
928
+ const paths = resolvePaths();
929
+ const conn = await connect(paths.socket);
930
+ if (conn === undefined) {
931
+ throw new CommandError(
932
+ "instance_unreachable",
933
+ `${paths.socket} に繋がりません (instance は動いていません)`,
934
+ );
935
+ }
936
+ try {
937
+ const greeting = await conn.ask({
938
+ op: "hello",
939
+ role: "session",
940
+ sid,
941
+ protocol_version: PROTOCOL_VERSION,
942
+ ...meta,
943
+ });
944
+ if (greeting["ok"] !== true) {
945
+ throw new CommandError("forbidden", `hello が拒否されました: ${JSON.stringify(greeting)}`);
946
+ }
947
+ const answer = await conn.ask(request);
948
+ if (answer["ok"] !== true) {
949
+ const error = answer["error"] as { code?: string; msg?: string } | undefined;
950
+ throw new CommandError(
951
+ (error?.code as CommandError["code"] | undefined) ?? "internal_error",
952
+ error?.msg ?? JSON.stringify(answer),
953
+ );
954
+ }
955
+ // A reply carries its result beside the envelope's own fields rather than
956
+ // nested under one, so what the op answered is the frame itself.
957
+ const { ok: _ok, request_id: _id, op: _op, ...result } = answer;
958
+ return result;
959
+ } finally {
960
+ conn.close();
961
+ }
962
+ }
963
+
964
+ /** What one attempt to record a `say` may cost before the speech goes ahead
965
+ * without it. The record is a nicety — which session spoke — and the speech is
966
+ * what the caller asked for, so an instance that has wedged costs latency once
967
+ * rather than silence. */
968
+ const SAY_POST_MS = 1_500;
969
+
970
+ /** How a speech process is started. Named so a test can watch the arguments
971
+ * without the machine making a sound. */
972
+ export type Spawn = (command: string[]) => { exited: Promise<number> };
973
+
974
+ const spawnSpeech: Spawn = (command) =>
975
+ Bun.spawn(command, { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
976
+
977
+ /** `ccmsg say [say-options] [text...]`: speak, and say who spoke.
978
+ *
979
+ * Every argument goes to the speech binary untouched, so its own flags work and
980
+ * a PATH shim delegating here changes nothing about what the caller gets. The
981
+ * one exception is a lone `--help`, which the binary does not define.
982
+ *
983
+ * No arguments is not an error: `echo hi | say` reads its text from stdin, and
984
+ * that is the form a shim exists to preserve. */
985
+ export async function say(args: readonly string[], spawn: Spawn = spawnSpeech): Promise<number> {
986
+ if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
987
+ process.stdout.write(help([ROOT, ROOT.children?.find((one) => one.name === "say") as Command]));
988
+ return 0;
989
+ }
990
+ await posted(args.join(" "));
991
+ const binary = process.env["CCMSG_SAY_BIN"] ?? SYSTEM_SAY;
992
+ return await spawn([binary, ...args]).exited;
993
+ }
994
+
995
+ /** Tell the instance this session spoke, and say nothing if it cannot be told.
996
+ *
997
+ * Best effort on purpose: no instance, a refused greeting or a socket that goes
998
+ * away under us all leave the speech itself untouched, and a message about the
999
+ * record would be noise in front of the thing the caller wanted. Text the
1000
+ * contract will not take — a bare `say` reading its text from stdin has none —
1001
+ * is nothing to record either. */
1002
+ async function posted(text: string): Promise<void> {
1003
+ const sid = process.env["CLAUDE_CODE_SESSION_ID"];
1004
+ if (text === "" || sid === undefined || sid === "") return;
1005
+ const conn = await connect(resolvePaths().socket);
1006
+ if (conn === undefined) return;
1007
+ let timer: ReturnType<typeof setTimeout> | undefined;
1008
+ const record = async (): Promise<void> => {
1009
+ const greeting = await conn.ask({
1010
+ op: "hello",
1011
+ role: "session",
1012
+ sid,
1013
+ protocol_version: PROTOCOL_VERSION,
1014
+ ...statedMeta(),
1015
+ });
1016
+ if (greeting["ok"] === true) await conn.ask({ op: "say_post", text });
1017
+ };
1018
+ const budget = new Promise<void>((resolve) => {
1019
+ timer = setTimeout(resolve, SAY_POST_MS);
1020
+ });
1021
+ try {
1022
+ // Raced rather than cancelled: an exchange that never answers is one this
1023
+ // waits out, and closing under it would leave a reply nobody resolves.
1024
+ await Promise.race([record(), budget]);
1025
+ } catch {
1026
+ // The instance went away mid-exchange. The speech is what was asked for
1027
+ // and it happens regardless.
1028
+ } finally {
1029
+ clearTimeout(timer);
1030
+ conn.close();
1031
+ }
1032
+ }
1033
+
1034
+ /** Long options and what is left over.
1035
+ *
1036
+ * Only the names the command declares are accepted, and `--` ends the options
1037
+ * so a text beginning with a dash is still a text. */
1038
+ function options(
1039
+ args: readonly string[],
1040
+ named: readonly string[],
1041
+ flags: readonly string[] = [],
1042
+ ): { named: Map<string, string>; flags: Set<string>; rest: string[] } {
1043
+ const values = new Map<string, string>();
1044
+ const raised = new Set<string>();
1045
+ const rest: string[] = [];
1046
+ for (let at = 0; at < args.length; at += 1) {
1047
+ const arg = args[at] as string;
1048
+ if (arg === "--") {
1049
+ rest.push(...args.slice(at + 1));
1050
+ break;
1051
+ }
1052
+ if (!arg.startsWith("--")) {
1053
+ rest.push(arg);
1054
+ continue;
1055
+ }
1056
+ const name = arg.slice(2);
1057
+ if (flags.includes(name)) {
1058
+ raised.add(name);
1059
+ continue;
1060
+ }
1061
+ if (!named.includes(name)) {
1062
+ throw new CommandError("invalid_args", `知らないオプションです: ${arg}`);
1063
+ }
1064
+ const value = args[at + 1];
1065
+ if (value === undefined) throw new CommandError("invalid_args", `${arg} には値が要ります`);
1066
+ values.set(name, value);
1067
+ at += 1;
1068
+ }
1069
+ return { named: values, flags: raised, rest };
1070
+ }
1071
+
1072
+ if (import.meta.main) {
1073
+ process.exitCode = await main(process.argv.slice(2));
1074
+ }