@ccmsg/cli 0.9.1 → 0.11.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/src/cli.ts CHANGED
@@ -30,10 +30,19 @@ import {
30
30
  tailOf,
31
31
  type Target,
32
32
  targetFor,
33
+ targetNamed,
33
34
  } from "./daemon/index.ts";
34
- import { readFileSync, writeFileSync } from "node:fs";
35
+ import {
36
+ copyFileSync,
37
+ existsSync,
38
+ mkdirSync,
39
+ readdirSync,
40
+ readFileSync,
41
+ statSync,
42
+ writeFileSync,
43
+ } from "node:fs";
35
44
  import { homedir } from "node:os";
36
- import { isAbsolute, join } from "node:path";
45
+ import { dirname, isAbsolute, join, relative } from "node:path";
37
46
  import { document } from "./transcript/items/index.ts";
38
47
  import { currentSession, DEFAULT_HARNESS, HARNESS, HARNESSES, isHarness } from "./harness/index.ts";
39
48
 
@@ -42,23 +51,20 @@ import { currentSession, DEFAULT_HARNESS, HARNESS, HARNESSES, isHarness } from "
42
51
  const SESSION_ENV = HARNESSES.flatMap((harness) => [...HARNESS[harness].sessionEnv]);
43
52
  import { hookEvent, type StatedMeta, statedMeta } from "./greeting/index.ts";
44
53
  import {
54
+ applied,
55
+ configFiles,
56
+ evaluate,
45
57
  isRunning,
46
- MERGE_RULES,
58
+ REJECTED_DIR,
59
+ resolveConfigDir,
47
60
  resolveConfigHome,
48
61
  resolvePaths,
49
62
  resolvePathsFor,
63
+ SATISFIED_FILE,
64
+ type Satisfied,
65
+ STATE_CONFIG_DIR,
50
66
  start,
51
67
  } from "./instance/index.ts";
52
-
53
- /** The merge rules as the help prints them: one line per field path, in the
54
- * order the schema declares them, so the table a person reads is the table the
55
- * merge runs on. */
56
- const MERGE_DOCS: readonly Doc[] = Object.entries(MERGE_RULES).map(([path, rule]) => [
57
- path,
58
- rule === "merge"
59
- ? "instances[] 側にある field だけを defaults に重ねる"
60
- : "instances[] 側にあれば丸ごと置換する (追加・和にはならない)",
61
- ]);
62
68
  import {
63
69
  type Agent,
64
70
  AGENTS,
@@ -151,22 +157,23 @@ const ROOT: Command = {
151
157
  {
152
158
  name: "run",
153
159
  summary: "この config home の instance を foreground で起動する (監督者の管理外)",
154
- usage: "ccmsg daemon run [dir]",
160
+ usage: "ccmsg daemon run [name | id | dir]",
155
161
  bare: true,
156
162
  run: (args) => runInstance(args[0]),
157
163
  },
158
164
  {
159
165
  name: "supervise",
160
- summary: "共通 config の instance を子プロセスとして起動し、落ちたら上げる",
166
+ summary: "検証済みの設定が挙げる instance を子として起動し、落ちたら上げる",
161
167
  usage: "ccmsg daemon supervise",
162
168
  bare: true,
163
169
  run: () => supervise(),
164
170
  },
165
171
  {
166
172
  name: "add",
167
- summary: "共通 config instances[] に足し、監督者が居れば起こさせる",
168
- usage: "ccmsg daemon add <dir> [--harness <種別>]",
173
+ summary: "config home instance として書き留め、監督者が居れば起こさせる",
174
+ usage: "ccmsg daemon add <dir> [--port <番号>] [--harness <種別>]",
169
175
  options: [
176
+ ["--port <番号>", "entry の待ち受けポート (既定は登録済みの最大 + 1 の空きポート)"],
170
177
  [
171
178
  "--harness <種別>",
172
179
  `config home が動かすもの: ${HARNESSES.join(" | ")} (既定 ${DEFAULT_HARNESS})`,
@@ -174,48 +181,59 @@ const ROOT: Command = {
174
181
  ],
175
182
  notes: [
176
183
  {
177
- title:
178
- "共通 config で instances[] の値が defaults に重なる規則 (掲載の無いパスは丸ごと置換):",
179
- docs: MERGE_DOCS,
184
+ title: "何がどのファイルに載るか (載っていないファイルは読まない):",
185
+ docs: [
186
+ ["config_v2.ts", "全 instance が受け取る値。`({builtin, config}) => config`"],
187
+ ["endpoints.json", "mesh の一覧 `[{id, endpoint}]`。別 host の分は人が足す"],
188
+ ["supervisor.json", "この host が起こす instance の id"],
189
+ [
190
+ "instances/instance-<id>.ts",
191
+ "1 instance 分の差分。`({builtin, default, config}) => config`",
192
+ ],
193
+ [
194
+ "ccmsg-config_v2.d.ts",
195
+ "設定ファイルが `import type` で参照する型 (ccmsg が置く)",
196
+ ],
197
+ ],
180
198
  },
181
199
  ],
182
200
  run: (args) => added(args),
183
201
  },
184
202
  {
185
203
  name: "remove",
186
- summary: "instances[] から外す (監督者は以後見ないが、子は止めない)",
187
- usage: "ccmsg daemon remove <dir>",
204
+ summary: "両 JSON から外して instances/instance-<id>.ts を消す (子は止めない)",
205
+ usage: "ccmsg daemon remove <name | id | dir>",
188
206
  run: (args) => removed(args[0]),
189
207
  },
190
208
  {
191
209
  name: "list",
192
- summary: "登録されている config home と、動いているかを並べる",
210
+ summary: "この host が起こす instance と、動いているかを並べる",
193
211
  usage: "ccmsg daemon list",
194
212
  bare: true,
195
- run: () => Promise.resolve(listInstances(process.env)),
213
+ run: () => listInstances(process.env),
196
214
  },
197
215
  {
198
216
  name: "start",
199
217
  summary: "監督者に、この config home の子を起こさせる",
200
- usage: "ccmsg daemon start <dir> | --all",
218
+ usage: "ccmsg daemon start <name | dir> | --all",
201
219
  run: (args) => supervised("supervise_start", args),
202
220
  },
203
221
  {
204
222
  name: "stop",
205
223
  summary: "監督者に、子を止めさせる (instance.shutdown、以後は上げ直さない)",
206
- usage: "ccmsg daemon stop <dir> | --all",
224
+ usage: "ccmsg daemon stop <name | dir> | --all",
207
225
  run: (args) => supervised("supervise_stop", args),
208
226
  },
209
227
  {
210
228
  name: "restart",
211
229
  summary: "監督者に、止めてから起こし直させる",
212
- usage: "ccmsg daemon restart <dir> | --all",
230
+ usage: "ccmsg daemon restart <name | dir> | --all",
213
231
  run: (args) => supervised("supervise_restart", args),
214
232
  },
215
233
  {
216
234
  name: "status",
217
235
  summary: "監督者が各子に instance.ping して version・network・peers を答える",
218
- usage: "ccmsg daemon status [dir] | --all",
236
+ usage: "ccmsg daemon status [name | id | dir] | --all",
219
237
  bare: true,
220
238
  run: (args) => supervised("supervise_status", args, true),
221
239
  },
@@ -229,10 +247,7 @@ const ROOT: Command = {
229
247
  summary: "登録用 URL と 6 桁コードを 1 組発行する (10 分で失効)",
230
248
  usage: "ccmsg daemon passkey add <unit> [endpoint] [--name <ラベル>]",
231
249
  options: [
232
- [
233
- "[endpoint]",
234
- "登録先の公開 base URL (末尾 /)。既定はこの instance が確定した endpoint",
235
- ],
250
+ ["[endpoint]", "登録先の公開 base URL (末尾 /)。既定はこの instance の endpoint"],
236
251
  ["--name <ラベル>", "誰宛に発行した URL かの管理ラベル"],
237
252
  ],
238
253
  run: (args) => passkeyAdd(args),
@@ -255,13 +270,63 @@ const ROOT: Command = {
255
270
  {
256
271
  name: "log",
257
272
  summary: "instance の daemon.log を出す (--all は行に id を足して多重化)",
258
- usage: "ccmsg daemon log [dir] | --all [--follow]",
273
+ usage: "ccmsg daemon log [name | id | dir] | --all [--follow]",
259
274
  options: [["--follow", "書き足される行を待ち続ける (Ctrl-C で終わり)"]],
260
275
  bare: true,
261
276
  run: (args) => daemonLog(args),
262
277
  },
263
278
  ],
264
279
  },
280
+ {
281
+ name: "config",
282
+ summary: "設定ファイルの検証と、適用済みとの差を見る",
283
+ usage: "ccmsg config <subcommand> [file] [options]",
284
+ notes: [
285
+ {
286
+ title: "人が編集するのは config dir、instance が読むのは検証済みの写し:",
287
+ docs: [
288
+ [
289
+ "編集用",
290
+ "$CCMSG_CONFIG_DIR (config_v2.ts / endpoints.json / supervisor.json / instances/)",
291
+ ],
292
+ ["適用用", "$CCMSG_STATE_DIR/config (検証を通った時だけ書かれる写しと satisfied.json)"],
293
+ ["通らない時", "適用済みのまま起動する。エラーは log と daemon status に出る"],
294
+ ],
295
+ },
296
+ ],
297
+ children: [
298
+ {
299
+ name: "list",
300
+ summary: "設定ファイルごとの検証結果と、適用済みとの差の有無を並べる",
301
+ usage: "ccmsg config list",
302
+ bare: true,
303
+ run: () => configList(),
304
+ },
305
+ {
306
+ name: "diff",
307
+ summary: "編集中のファイルと適用済みの写しの差 (--satisfied は適用したら何が変わるか)",
308
+ usage: "ccmsg config diff [file] | --satisfied",
309
+ options: [["--satisfied", "今の satisfied.json と、編集中から評価し直した物の差"]],
310
+ bare: true,
311
+ run: (args) => configDiff(args),
312
+ },
313
+ {
314
+ name: "show",
315
+ summary: "編集中を検証・評価して satisfied の形で表示する (書き込まない)",
316
+ usage: "ccmsg config show [--applied]",
317
+ options: [["--applied", "今使っている satisfied.json を表示する"]],
318
+ bare: true,
319
+ run: (args) => configShow(args),
320
+ },
321
+ {
322
+ name: "revert",
323
+ summary: "適用済みの写しで編集中を上書きする (壊れた方は退避してパスを出す)",
324
+ usage: "ccmsg config revert <file> | --all",
325
+ options: [["--all", "差のあるファイルを全部戻す"]],
326
+ run: (args) => configRevert(args),
327
+ },
328
+ ],
329
+ },
265
330
  {
266
331
  name: "service",
267
332
  summary: "監督者 (ccmsg daemon supervise) を launchd / systemd に登録する",
@@ -568,15 +633,19 @@ function section(lines: string[], title: string, docs: readonly Doc[] | undefine
568
633
  lines.push("");
569
634
  }
570
635
 
571
- /** `ccmsg daemon run [dir]`: this config home's instance, in the foreground. */
572
- async function runInstance(dir: string | undefined): Promise<unknown> {
573
- const named = dir ?? resolveConfigHome();
574
- const home = configHome(named, harnessFor(process.env, named));
636
+ /** `ccmsg daemon run [name | dir]`: this config home's instance, in the
637
+ * foreground. */
638
+ async function runInstance(given: string | undefined): Promise<unknown> {
639
+ const named = (await dirOf(given)) ?? resolveConfigHome();
640
+ const home = configHome(named, await harnessFor(process.env, named));
575
641
  // The directory is handed over rather than put in the environment: the
576
642
  // instance would otherwise read it back through the question "which session
577
643
  // is this process inside", and a `daemon run` issued from a session of
578
644
  // another harness would answer for that session's config home (§3.8).
579
- const outcome = await start({ configHome: home });
645
+ // With a supervisor up, it is the one that reads the files and writes down
646
+ // what held (§8.2); this start reads what it applied. With none, this
647
+ // process is the only one there is, so it does both.
648
+ const outcome = await start({ configHome: home, settle: !(await reachable()) });
580
649
  if (!isRunning(outcome)) {
581
650
  throw new CommandError(
582
651
  "file_exists",
@@ -620,36 +689,268 @@ async function supervise(): Promise<unknown> {
620
689
  * the supervisor reads the list once (DV-Q8) and would otherwise not know
621
690
  * until it is restarted. */
622
691
  async function added(args: readonly string[]): Promise<unknown> {
623
- const { named, rest } = options(args, ["harness"]);
692
+ const { named, rest } = options(args, ["harness", "port"]);
624
693
  const dir = rest[0];
625
694
  const stated = named.get("harness");
695
+ const port = named.get("port");
626
696
  if (dir === undefined) {
627
- throw new CommandError("invalid_args", "使い方: ccmsg daemon add <dir> [--harness <種別>]");
697
+ throw new CommandError(
698
+ "invalid_args",
699
+ "使い方: ccmsg daemon add <dir> [--port <番号>] [--harness <種別>]",
700
+ );
628
701
  }
629
702
  if (stated !== undefined && !isHarness(stated)) {
630
703
  throw new CommandError("invalid_args", `--harness は ${HARNESSES.join(" | ")} のどれかです`);
631
704
  }
632
- const row = addToConfig(process.env, dir, stated ?? DEFAULT_HARNESS);
705
+ if (port !== undefined && !/^\d{1,5}$/.test(port)) {
706
+ throw new CommandError("invalid_args", "--port は 0 から 65535 の番号です");
707
+ }
708
+ const row = await addToConfig(process.env, dir, {
709
+ ...(stated === undefined ? {} : { harness: stated }),
710
+ ...(port === undefined ? {} : { port: Number(port) }),
711
+ });
633
712
  if (!(await reachable())) return { ...row, supervised: false };
634
713
  const started = (await ask({ op: "supervise_add", dir: row.dir })) as Record<string, unknown>;
635
- return { ...started, supervised: true };
714
+ return { ...started, name: row.name, supervised: true };
636
715
  }
637
716
 
638
- /** `ccmsg daemon remove <dir>`: take it off the list, and stop looking after it.
717
+ /** `ccmsg mesh <what> [endpoint]`: the mesh endpoints this host does not serve
718
+ * itself.
719
+ *
720
+ * Its own command rather than one under `daemon`, because what it edits is not
721
+ * one instance's anything: every instance of this host is in the same mesh
722
+ * (§7.1), so the list is the host's. `peers` is what a session list is called,
723
+ * which is why this is called what the thing itself is called.
639
724
  *
640
- * The instance itself is left alone: a list edit is not a shutdown, and a
725
+ * An addition takes effect when the instances next start, for the reason
726
+ * nothing else reloads either (DV-Q8). A removal is told to whoever is running
727
+ * as well as written down: an endpoint taken off the list is one this host is
728
+ * not to be talking to, and leaving a live link up until the next restart would
729
+ * be leaving exactly the connection that was just revoked. */
730
+ /** `ccmsg daemon remove <name | id | dir>`: take its file away, and stop
731
+ * looking after it.
732
+ *
733
+ * The instance itself is left alone: removing the file is not a shutdown, and a
641
734
  * session already talking to that instance keeps it. `daemon stop` is how one
642
735
  * is stopped, and keeping the two apart is what makes that true. */
643
- async function removed(dir: string | undefined): Promise<unknown> {
644
- if (dir === undefined) {
645
- throw new CommandError("invalid_args", "使い方: ccmsg daemon remove <dir>");
736
+ async function removed(ref: string | undefined): Promise<unknown> {
737
+ if (ref === undefined) {
738
+ throw new CommandError("invalid_args", "使い方: ccmsg daemon remove <name | id | dir>");
646
739
  }
647
- const row = removeFromConfig(process.env, dir);
740
+ const row = await removeFromConfig(process.env, ref);
648
741
  if (!(await reachable())) return { ...row, supervised: false };
649
742
  await ask({ op: "supervise_remove", dir: row.dir });
650
743
  return { ...row, supervised: false };
651
744
  }
652
745
 
746
+ async function configList(): Promise<unknown> {
747
+ const configDir = resolveConfigDir();
748
+ const stateRoot = resolvePaths().stateRoot;
749
+ const read = await evaluate(configDir);
750
+ const ids = (read.satisfied ?? applied(stateRoot))?.supervisor.instances ?? [];
751
+ const files = configFiles(configDir, ids).map((file) => {
752
+ const copy = join(stateRoot, STATE_CONFIG_DIR, relative(configDir, file));
753
+ return {
754
+ file,
755
+ present: existsSync(file),
756
+ // What is wrong with this one, as the check said it: a person reading
757
+ // this is about to open the file, so the line they need is beside it.
758
+ problems: read.problems.filter((one) => one.file === file).map((one) => one.msg),
759
+ applied: existsSync(copy),
760
+ differs: existsSync(file) && existsSync(copy) ? !same(file, copy) : existsSync(file),
761
+ };
762
+ });
763
+ const rejected = rejectedFiles(stateRoot);
764
+ return {
765
+ ok: read.satisfied !== undefined,
766
+ files,
767
+ ...(read.problems.length === 0 ? {} : { problems: read.problems }),
768
+ rejected: {
769
+ count: rejected.length,
770
+ ...(rejected[0] === undefined
771
+ ? {}
772
+ : { latest: rejected[0].at, dir: join(stateRoot, REJECTED_DIR) }),
773
+ },
774
+ };
775
+ }
776
+
777
+ /** What one edited file and its checked copy differ by, or what applying the
778
+ * edits would change about the whole. */
779
+ async function configDiff(args: readonly string[]): Promise<unknown> {
780
+ const parsed = options(args, [], ["satisfied"]);
781
+ const configDir = resolveConfigDir();
782
+ const stateRoot = resolvePaths().stateRoot;
783
+ if (parsed.flags.has("satisfied")) {
784
+ const read = await evaluate(configDir);
785
+ if (read.satisfied === undefined) {
786
+ throw new CommandError(
787
+ "invalid_args",
788
+ `設定が通らないので比べられません: ${read.problems.map((one) => `${one.file}: ${one.msg}`).join("; ")}`,
789
+ );
790
+ }
791
+ const standing = applied(stateRoot);
792
+ return {
793
+ file: join(stateRoot, STATE_CONFIG_DIR, SATISFIED_FILE),
794
+ diff: unified(
795
+ standing === undefined ? "" : `${JSON.stringify(standing, null, 2)}\n`,
796
+ `${JSON.stringify(read.satisfied, null, 2)}\n`,
797
+ ),
798
+ };
799
+ }
800
+ const named = parsed.rest[0];
801
+ const ids =
802
+ (await evaluate(configDir)).satisfied?.supervisor.instances ??
803
+ applied(stateRoot)?.supervisor.instances ??
804
+ [];
805
+ const wanted =
806
+ named === undefined
807
+ ? configFiles(configDir, ids)
808
+ : [isAbsolute(named) ? named : join(configDir, named)];
809
+ const diffs = wanted.flatMap((file) => {
810
+ const copy = join(stateRoot, STATE_CONFIG_DIR, relative(configDir, file));
811
+ const before = readOr(copy);
812
+ const after = readOr(file);
813
+ if (before === after) return [];
814
+ return [{ file, applied: copy, diff: unified(before, after) }];
815
+ });
816
+ return { files: diffs };
817
+ }
818
+
819
+ async function configShow(args: readonly string[]): Promise<unknown> {
820
+ const parsed = options(args, [], ["applied"]);
821
+ const stateRoot = resolvePaths().stateRoot;
822
+ if (parsed.flags.has("applied")) {
823
+ const standing = applied(stateRoot);
824
+ if (standing === undefined) throw new CommandError("not_found", "まだ何も適用されていません");
825
+ return standing;
826
+ }
827
+ // A dry run: the files are read and the settings functions are called, and
828
+ // nothing is written. They are expected to have no side effects for exactly
829
+ // this reason (§8.2).
830
+ const read = await evaluate(resolveConfigDir());
831
+ if (read.satisfied === undefined) {
832
+ throw new CommandError(
833
+ "invalid_args",
834
+ read.problems.map((one) => `${one.file}: ${one.msg}`).join("; "),
835
+ );
836
+ }
837
+ return read.satisfied as Satisfied;
838
+ }
839
+
840
+ /** Put the checked copy back over what is being edited.
841
+ *
842
+ * What is overwritten is kept, under the time it was taken, and its path is
843
+ * printed: a person reverting has just lost an edit, and the one thing they
844
+ * need is where it went. */
845
+ async function configRevert(args: readonly string[]): Promise<unknown> {
846
+ const parsed = options(args, [], ["all"]);
847
+ const named = parsed.rest[0];
848
+ const all = parsed.flags.has("all");
849
+ if (named === undefined && !all) {
850
+ throw new CommandError("invalid_args", "使い方: ccmsg config revert <file> | --all");
851
+ }
852
+ const configDir = resolveConfigDir();
853
+ const stateRoot = resolvePaths().stateRoot;
854
+ const ids = applied(stateRoot)?.supervisor.instances ?? [];
855
+ const wanted = all
856
+ ? configFiles(configDir, ids)
857
+ : [isAbsolute(named as string) ? (named as string) : join(configDir, named as string)];
858
+ const at = new Date().toISOString().replace(/[:.]/g, "-");
859
+ const done: { file: string; kept?: string }[] = [];
860
+ for (const file of wanted) {
861
+ const copy = join(stateRoot, STATE_CONFIG_DIR, relative(configDir, file));
862
+ if (!existsSync(copy)) {
863
+ if (!all) throw new CommandError("not_found", `${file} の検証済みの写しがありません`);
864
+ continue;
865
+ }
866
+ if (readOr(file) === readOr(copy)) continue;
867
+ let kept: string | undefined;
868
+ if (existsSync(file)) {
869
+ kept = join(stateRoot, REJECTED_DIR, `${relative(configDir, file)}.${at}`);
870
+ mkdirSync(dirname(kept), { recursive: true });
871
+ copyFileSync(file, kept);
872
+ }
873
+ mkdirSync(dirname(file), { recursive: true });
874
+ copyFileSync(copy, file);
875
+ done.push({ file, ...(kept === undefined ? {} : { kept }) });
876
+ }
877
+ return { reverted: done };
878
+ }
879
+
880
+ /** What was put aside by a revert, newest first. */
881
+ function rejectedFiles(stateRoot: string): { file: string; at: string }[] {
882
+ const dir = join(stateRoot, REJECTED_DIR);
883
+ const found: { file: string; at: string }[] = [];
884
+ const walk = (at: string): void => {
885
+ let entries: string[];
886
+ try {
887
+ entries = readdirSync(at);
888
+ } catch {
889
+ return;
890
+ }
891
+ for (const entry of entries) {
892
+ const path = join(at, entry);
893
+ if (statSync(path).isDirectory()) walk(path);
894
+ else found.push({ file: path, at: statSync(path).mtime.toISOString() });
895
+ }
896
+ };
897
+ walk(dir);
898
+ return found.sort((one, two) => (one.at < two.at ? 1 : -1));
899
+ }
900
+
901
+ function readOr(file: string): string {
902
+ try {
903
+ return readFileSync(file, "utf8");
904
+ } catch {
905
+ return "";
906
+ }
907
+ }
908
+
909
+ function same(one: string, two: string): boolean {
910
+ return readOr(one) === readOr(two);
911
+ }
912
+
913
+ /** A diff a person can read, line by line.
914
+ *
915
+ * Written here rather than shelled out to: what this compares is two files
916
+ * this process already has, and a command that answers in JSON cannot hand its
917
+ * caller a pager. */
918
+ function unified(before: string, after: string): string[] {
919
+ const from = before === "" ? [] : before.split("\n");
920
+ const to = after === "" ? [] : after.split("\n");
921
+ const lines: string[] = [];
922
+ let at = 0;
923
+ let here = 0;
924
+ while (at < from.length || here < to.length) {
925
+ const left = from[at];
926
+ const right = to[here];
927
+ if (left === right) {
928
+ at += 1;
929
+ here += 1;
930
+ continue;
931
+ }
932
+ if (right !== undefined && !from.slice(at).includes(right)) {
933
+ lines.push(`+ ${right}`);
934
+ here += 1;
935
+ continue;
936
+ }
937
+ if (left !== undefined && !to.slice(here).includes(left)) {
938
+ lines.push(`- ${left}`);
939
+ at += 1;
940
+ continue;
941
+ }
942
+ if (left !== undefined) {
943
+ lines.push(`- ${left}`);
944
+ at += 1;
945
+ }
946
+ if (right !== undefined) {
947
+ lines.push(`+ ${right}`);
948
+ here += 1;
949
+ }
950
+ }
951
+ return lines;
952
+ }
953
+
653
954
  /** The four commands that are requests to the supervisor rather than things
654
955
  * this process does.
655
956
  *
@@ -666,14 +967,26 @@ async function supervised(
666
967
  const all = parsed.flags.has("all");
667
968
  const named = parsed.rest[0];
668
969
  if (all && named !== undefined) {
669
- throw new CommandError("invalid_args", "--all と dir は同時に指定できません");
970
+ throw new CommandError("invalid_args", "--all と name は同時に指定できません");
670
971
  }
671
972
  if (all) return await ask({ op, all: true });
672
- const dir = named ?? (hereByDefault ? resolveConfigHome() : undefined);
673
- if (dir === undefined) throw new CommandError("invalid_args", "dir か --all が要ります");
973
+ const dir = (await dirOf(named)) ?? (hereByDefault ? resolveConfigHome() : undefined);
974
+ if (dir === undefined) throw new CommandError("invalid_args", "name か --all が要ります");
674
975
  return await ask({ op, dir });
675
976
  }
676
977
 
978
+ /** The config home a command was given, by either of the two things a person
979
+ * has to hand: the name they added it under, or the directory itself.
980
+ *
981
+ * A name first, because that is what `daemon add` took and so what a person
982
+ * has written down; anything nobody registered under that name is taken as the
983
+ * directory it looks like, which is what makes `daemon run` on an unregistered
984
+ * config home reachable. */
985
+ async function dirOf(given: string | undefined): Promise<string | undefined> {
986
+ if (given === undefined) return undefined;
987
+ return (await targetNamed(process.env, given))?.dir ?? given;
988
+ }
989
+
677
990
  /** `ccmsg service <what>`: the supervisor's registration with the host. */
678
991
  async function serviceOp(
679
992
  what: "register" | "unregister" | "start" | "stop" | "status",
@@ -694,7 +1007,7 @@ async function serviceOp(
694
1007
  kind: service.kind,
695
1008
  unit: service.unitFile,
696
1009
  ...state,
697
- instances: registered(process.env).map((target) => {
1010
+ instances: (await registered(process.env)).map((target) => {
698
1011
  const row = rowFor(target);
699
1012
  return { id: row.id, dir: row.dir, running: row.running };
700
1013
  }),
@@ -704,12 +1017,12 @@ async function serviceOp(
704
1017
  /** The passkey commands, which are asked of the instance itself rather than of
705
1018
  * the supervisor.
706
1019
  *
707
- * They travel on the instance's unix socket and nowhere else: registration is
1020
+ * They travel on that instance's unix socket and nowhere else: registration is
708
1021
  * local by design (DR-0001 §2.2), and reaching that address is what says the
709
1022
  * caller is on the machine. They are not ops of the contract for the same
710
1023
  * reason — the contract is what reaches an instance over a network. */
711
- async function passkeyAsk(unit: string | undefined, request: Record<string, unknown>) {
712
- const target = targetFor(process.env, unit ?? resolveConfigHome());
1024
+ /** One administrative request, on one instance's own unix socket. */
1025
+ async function askInstance(target: Target, request: Record<string, unknown>) {
713
1026
  const conn = await connect(target.paths.socket);
714
1027
  if (conn === undefined) {
715
1028
  throw new CommandError("not_found", `${target.dir} の instance は動いていません`);
@@ -727,6 +1040,13 @@ async function passkeyAsk(unit: string | undefined, request: Record<string, unkn
727
1040
  }
728
1041
  }
729
1042
 
1043
+ async function passkeyAsk(unit: string | undefined, request: Record<string, unknown>) {
1044
+ return await askInstance(
1045
+ targetFor(process.env, (await dirOf(unit)) ?? resolveConfigHome()),
1046
+ request,
1047
+ );
1048
+ }
1049
+
730
1050
  /** `ccmsg daemon passkey add`: one registration URL, and the code that goes
731
1051
  * with it.
732
1052
  *
@@ -772,7 +1092,7 @@ async function daemonLog(args: readonly string[]): Promise<undefined> {
772
1092
  // shape whether the host runs one or five.
773
1093
  const many = parsed.flags.has("all");
774
1094
  const targets = many
775
- ? registered(process.env)
1095
+ ? await registered(process.env)
776
1096
  : [targetFor(process.env, parsed.rest[0] ?? resolveConfigHome())];
777
1097
  const write = (target: Target, lines: readonly string[]): void => {
778
1098
  for (const line of lines) {
@@ -1191,7 +1511,7 @@ async function plugin(
1191
1511
  }
1192
1512
  // `status` with no agent named answers for the config home this process
1193
1513
  // belongs to, which is what the instance there runs.
1194
- const which = agent ?? harnessFor(process.env, resolvePaths().configHome);
1514
+ const which = agent ?? (await harnessFor(process.env, resolvePaths().configHome));
1195
1515
  // The config home is that agent's own, and not whichever variable happens to
1196
1516
  // be set: a Codex session started from a Claude Code session carries both,
1197
1517
  // and an install that read the wrong one would write Codex's hooks into