@ccmsg/cli 0.2.13 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.2.13",
3
+ "version": "0.3.1",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -20,7 +20,7 @@
20
20
  "test": "bun test"
21
21
  },
22
22
  "dependencies": {
23
- "@ccmsg/protocol": "1.9.0"
23
+ "@ccmsg/protocol": "1.10.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  connect,
10
10
  expectedInstances,
11
11
  follow,
12
+ harnessFor,
12
13
  idOf,
13
14
  labelled,
14
15
  list as listInstances,
@@ -23,10 +24,23 @@ import {
23
24
  type Target,
24
25
  targetFor,
25
26
  } from "./daemon/index.ts";
26
- import { join } from "node:path";
27
+ import { homedir } from "node:os";
28
+ import { isAbsolute, join } from "node:path";
29
+ import { currentSession, DEFAULT_HARNESS, HARNESS, HARNESSES, isHarness } from "./harness/index.ts";
30
+
31
+ /** The variables a session is named by, for the help and for the message a
32
+ * command answers with when it finds none of them. */
33
+ const SESSION_ENV = HARNESSES.flatMap((harness) => [...HARNESS[harness].sessionEnv]);
27
34
  import { hookEvent, type StatedMeta, statedMeta } from "./greeting/index.ts";
28
- import { isRunning, resolveConfigHome, resolvePaths, start } from "./instance/index.ts";
29
35
  import {
36
+ isRunning,
37
+ resolveConfigHome,
38
+ resolvePaths,
39
+ resolvePathsFor,
40
+ start,
41
+ } from "./instance/index.ts";
42
+ import {
43
+ type Agent,
30
44
  AGENTS,
31
45
  install,
32
46
  type Outcome,
@@ -36,6 +50,16 @@ import {
36
50
  import { type Run, runCommand, serviceFor } from "./service/index.ts";
37
51
  import { VERSION } from "./version.ts";
38
52
 
53
+ /** The session this process runs inside, as its environment says (§3.8).
54
+ *
55
+ * One reading for every command that speaks as a session: which harness
56
+ * claimed the process settles both who the sender is and which instance it
57
+ * reaches, and a command that took the two from different places could greet
58
+ * one instance as a session of the other. */
59
+ function ownSid(): string | undefined {
60
+ return currentSession(process.env)?.sid;
61
+ }
62
+
39
63
  /** Where a hook event is read from. Named for the same reason a speech binary
40
64
  * is: a test drives the two commands a harness fires without a standard input
41
65
  * of its own to write to. */
@@ -117,8 +141,14 @@ const ROOT: Command = {
117
141
  {
118
142
  name: "add",
119
143
  summary: "共通 config の instances[] に足し、監督者が居れば起こさせる",
120
- usage: "ccmsg daemon add <dir>",
121
- run: (args) => added(args[0]),
144
+ usage: "ccmsg daemon add <dir> [--harness <種別>]",
145
+ options: [
146
+ [
147
+ "--harness <種別>",
148
+ `config home が動かすもの: ${HARNESSES.join(" | ")} (既定 ${DEFAULT_HARNESS})`,
149
+ ],
150
+ ],
151
+ run: (args) => added(args),
122
152
  },
123
153
  {
124
154
  name: "remove",
@@ -261,7 +291,10 @@ const ROOT: Command = {
261
291
  name: "install",
262
292
  summary: "そのエージェントに ccmsg のプラグインを入れる",
263
293
  usage: "ccmsg plugin install <agent>",
264
- options: [["", "開いているセッションには /reload-plugins で反映される"]],
294
+ options: [
295
+ ["claude", "開いているセッションには /reload-plugins で反映される"],
296
+ ["codex", "config home に直接置く。hook は codex 側で trust してから効く"],
297
+ ],
265
298
  run: (args) => plugin("install", args[0]),
266
299
  },
267
300
  {
@@ -286,7 +319,7 @@ const ROOT: Command = {
286
319
  options: [
287
320
  ["--all", "mesh 越しの instance が言っている分も含める"],
288
321
  ["--json", "JSON で答える (既定、この CLI は常に JSON で答える)"],
289
- ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
322
+ ["--sid <sid>", `自分のセッション ID (既定は ${SESSION_ENV.join(" / ")})`],
290
323
  ],
291
324
  env: sessionEnv(),
292
325
  run: (args) => peers(args),
@@ -306,7 +339,7 @@ const ROOT: Command = {
306
339
  name: "post",
307
340
  summary: "別のセッションへメッセージを送る",
308
341
  usage: "ccmsg post <sid> <text> [--sid <自分の sid>]",
309
- options: [["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"]],
342
+ options: [["--sid <sid>", `自分のセッション ID (既定は ${SESSION_ENV.join(" / ")})`]],
310
343
  env: sessionEnv(),
311
344
  run: (args) => post(args),
312
345
  },
@@ -315,7 +348,7 @@ const ROOT: Command = {
315
348
  summary: "受け取ったメッセージに返信する",
316
349
  usage: "ccmsg reply <mid> <text> [--to <相手の sid>]",
317
350
  options: [
318
- ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
351
+ ["--sid <sid>", `自分のセッション ID (既定は ${SESSION_ENV.join(" / ")})`],
319
352
  ["--to <sid>", "宛先セッション (封筒の ccmsg-from)。省略すると人への返信"],
320
353
  ],
321
354
  env: sessionEnv(),
@@ -326,7 +359,7 @@ const ROOT: Command = {
326
359
  summary: "見ている人へ一行知らせる (保持されない、返事も来ない)",
327
360
  usage: "ccmsg notify <text> [--about <sid>]",
328
361
  options: [
329
- ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
362
+ ["--sid <sid>", `自分のセッション ID (既定は ${SESSION_ENV.join(" / ")})`],
330
363
  ["--about <sid>", "知らせるセッション (既定は自分)"],
331
364
  ],
332
365
  env: sessionEnv(),
@@ -338,7 +371,7 @@ const ROOT: Command = {
338
371
  usage: "ccmsg stopping [--reason <text>] [--hook]",
339
372
  bare: true,
340
373
  options: [
341
- ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
374
+ ["--sid <sid>", `自分のセッション ID (既定は ${SESSION_ENV.join(" / ")})`],
342
375
  ["--reason <text>", "終わる理由 (表示用、任意)"],
343
376
  ["--hook", "harness の hook イベント JSON を標準入力から読む"],
344
377
  ],
@@ -351,7 +384,7 @@ const ROOT: Command = {
351
384
  usage: "ccmsg hello [--cwd <path>] [--repo <name>] ... [--hook]",
352
385
  bare: true,
353
386
  options: [
354
- ["--sid <sid>", "自分のセッション ID (既定は CLAUDE_CODE_SESSION_ID)"],
387
+ ["--sid <sid>", `自分のセッション ID (既定は ${SESSION_ENV.join(" / ")})`],
355
388
  ["--cwd <path>", "作業ディレクトリ"],
356
389
  ["--repo <name>", "リポジトリの表示名"],
357
390
  ["--ws <name>", "ワークスペース名"],
@@ -370,7 +403,7 @@ const ROOT: Command = {
370
403
  usage: "ccmsg say [say-options] [text...]",
371
404
  options: [["", `引数は ${SYSTEM_SAY} へそのまま渡す (単独の --help だけが例外)`]],
372
405
  env: [
373
- ["CLAUDE_CODE_SESSION_ID", "喋ったセッションの名乗り"],
406
+ [SESSION_ENV.join(" / "), "喋ったセッションの名乗り"],
374
407
  ["CCMSG_SAY_BIN", `発声に使うバイナリ (既定は ${SYSTEM_SAY})`],
375
408
  ],
376
409
  raw: (args) => say(args),
@@ -379,7 +412,7 @@ const ROOT: Command = {
379
412
  };
380
413
 
381
414
  function sessionEnv(): readonly Doc[] {
382
- return [["CLAUDE_CODE_SESSION_ID", "自分のセッション ID"]];
415
+ return [[SESSION_ENV.join(" / "), "自分のセッション ID"]];
383
416
  }
384
417
 
385
418
  /** Walk the tree, and answer at the level the arguments reach.
@@ -471,8 +504,13 @@ function section(lines: string[], title: string, docs: readonly Doc[] | undefine
471
504
 
472
505
  /** `ccmsg daemon run [dir]`: this config home's instance, in the foreground. */
473
506
  async function runInstance(dir: string | undefined): Promise<unknown> {
474
- const home = configHome(dir ?? resolveConfigHome());
475
- const outcome = await start({ env: { ...process.env, CLAUDE_CONFIG_DIR: home } });
507
+ const named = dir ?? resolveConfigHome();
508
+ const home = configHome(named, harnessFor(process.env, named));
509
+ // The directory is handed over rather than put in the environment: the
510
+ // instance would otherwise read it back through the question "which session
511
+ // is this process inside", and a `daemon run` issued from a session of
512
+ // another harness would answer for that session's config home (§3.8).
513
+ const outcome = await start({ configHome: home });
476
514
  if (!isRunning(outcome)) {
477
515
  throw new CommandError(
478
516
  "file_exists",
@@ -515,9 +553,17 @@ async function supervise(): Promise<unknown> {
515
553
  * next supervisor starts it. Told rather than left to be discovered, because
516
554
  * the supervisor reads the list once (DV-Q8) and would otherwise not know
517
555
  * until it is restarted. */
518
- async function added(dir: string | undefined): Promise<unknown> {
519
- if (dir === undefined) throw new CommandError("invalid_args", "使い方: ccmsg daemon add <dir>");
520
- const row = addToConfig(process.env, dir);
556
+ async function added(args: readonly string[]): Promise<unknown> {
557
+ const { named, rest } = options(args, ["harness"]);
558
+ const dir = rest[0];
559
+ const stated = named.get("harness");
560
+ if (dir === undefined) {
561
+ throw new CommandError("invalid_args", "使い方: ccmsg daemon add <dir> [--harness <種別>]");
562
+ }
563
+ if (stated !== undefined && !isHarness(stated)) {
564
+ throw new CommandError("invalid_args", `--harness は ${HARNESSES.join(" | ")} のどれかです`);
565
+ }
566
+ const row = addToConfig(process.env, dir, stated ?? DEFAULT_HARNESS);
521
567
  if (!(await reachable())) return { ...row, supervised: false };
522
568
  const started = (await ask({ op: "supervise_add", dir: row.dir })) as Record<string, unknown>;
523
569
  return { ...started, supervised: true };
@@ -735,7 +781,7 @@ function never(release: () => void): Promise<void> {
735
781
  * the same list minus that field. */
736
782
  function peers(args: readonly string[]): Promise<unknown> {
737
783
  const parsed = options(args, ["sid"], ["all", "json"]);
738
- const sid = parsed.named.get("sid") ?? process.env["CLAUDE_CODE_SESSION_ID"];
784
+ const sid = parsed.named.get("sid") ?? ownSid();
739
785
  return topic(
740
786
  "peers",
741
787
  parsed.flags.has("all"),
@@ -884,7 +930,7 @@ export async function hello(args: readonly string[], read?: Read): Promise<unkno
884
930
  ["hook"],
885
931
  );
886
932
  const event = parsed.flags.has("hook") ? await hookEvent(read) : {};
887
- const sid = parsed.named.get("sid") ?? event.sid ?? process.env["CLAUDE_CODE_SESSION_ID"];
933
+ const sid = parsed.named.get("sid") ?? event.sid ?? ownSid();
888
934
  if (sid === undefined || sid === "") return { greeted: false, reason: "no_session_id" };
889
935
  const meta = stated(parsed.named, event);
890
936
  const paths = resolvePaths();
@@ -936,6 +982,18 @@ function only(field: keyof StatedMeta, value: string | undefined): StatedMeta {
936
982
  return value === undefined || value === "" ? {} : { [field]: value };
937
983
  }
938
984
 
985
+ /** The config home of one agent, as that agent's own variable names it.
986
+ *
987
+ * Its own and no other's: the point of naming the agent is to install into the
988
+ * home that agent reads, and a fallback to somebody else's variable would put
989
+ * the files where the agent will never look. Unset means the agent's own
990
+ * default home, which is where that agent looks when nobody says otherwise. */
991
+ function homeOf(agent: Agent): string {
992
+ const named = process.env[HARNESS[agent].homeEnv];
993
+ if (named !== undefined && named !== "" && isAbsolute(named)) return named;
994
+ return join(homedir(), agent === "codex" ? ".codex" : ".claude");
995
+ }
996
+
939
997
  /** `ccmsg plugin <what> <agent>`: what ccmsg installs into an agent, and what
940
998
  * it takes back out.
941
999
  *
@@ -946,10 +1004,10 @@ async function plugin(
946
1004
  what: "install" | "status" | "uninstall",
947
1005
  agent: string | undefined,
948
1006
  ): Promise<unknown> {
949
- if (agent !== undefined && agent !== "claude") {
1007
+ if (agent !== undefined && !isHarness(agent)) {
950
1008
  throw new CommandError(
951
1009
  "invalid_args",
952
- `${agent} 用のプラグインはまだありません (今あるのは ${AGENTS.join(", ")})`,
1010
+ `${agent} 用のプラグインはありません (今あるのは ${AGENTS.join(", ")})`,
953
1011
  );
954
1012
  }
955
1013
  if (what !== "status" && agent === undefined) {
@@ -958,13 +1016,22 @@ async function plugin(
958
1016
  `使い方: ccmsg plugin ${what} <agent> (今あるのは ${AGENTS.join(", ")})`,
959
1017
  );
960
1018
  }
961
- const paths = resolvePaths();
1019
+ // `status` with no agent named answers for the config home this process
1020
+ // belongs to, which is what the instance there runs.
1021
+ const which = agent ?? harnessFor(process.env, resolvePaths().configHome);
1022
+ // The config home is that agent's own, and not whichever variable happens to
1023
+ // be set: a Codex session started from a Claude Code session carries both,
1024
+ // and an install that read the wrong one would write Codex's hooks into
1025
+ // Claude Code's config home (§3.8). The marker check is what says the
1026
+ // directory really is that agent's.
1027
+ const home = configHome(homeOf(which), which);
1028
+ const paths = resolvePathsFor(home);
962
1029
  const outcome: Outcome =
963
1030
  what === "install"
964
- ? await install(paths, VERSION)
1031
+ ? await install(paths, which, VERSION)
965
1032
  : what === "status"
966
- ? await pluginStatus(paths)
967
- : await uninstall(paths);
1033
+ ? await pluginStatus(paths, which)
1034
+ : await uninstall(paths, which);
968
1035
  // A refused step is an error rather than an answer, so the command's exit
969
1036
  // code says what happened without the report having to be read. The report
970
1037
  // itself travels with it: what was done before the refusal is what the next
@@ -1009,11 +1076,11 @@ async function call(
1009
1076
  request: Record<string, unknown>,
1010
1077
  meta: StatedMeta = statedMeta(),
1011
1078
  ): Promise<unknown> {
1012
- const sid = named ?? process.env["CLAUDE_CODE_SESSION_ID"];
1079
+ const sid = named ?? ownSid();
1013
1080
  if (sid === undefined || sid === "") {
1014
1081
  throw new CommandError(
1015
1082
  "invalid_args",
1016
- "自分のセッション ID が分かりません (--sid か CLAUDE_CODE_SESSION_ID)",
1083
+ `自分のセッション ID が分かりません (--sid か ${SESSION_ENV.join(" / ")})`,
1017
1084
  );
1018
1085
  }
1019
1086
  const paths = resolvePaths();
@@ -1091,7 +1158,7 @@ export async function say(args: readonly string[], spawn: Spawn = spawnSpeech):
1091
1158
  * contract will not take — a bare `say` reading its text from stdin has none —
1092
1159
  * is nothing to record either. */
1093
1160
  async function posted(text: string): Promise<void> {
1094
- const sid = process.env["CLAUDE_CODE_SESSION_ID"];
1161
+ const sid = ownSid();
1095
1162
  if (text === "" || sid === undefined || sid === "") return;
1096
1163
  const conn = await connect(resolvePaths().socket);
1097
1164
  if (conn === undefined) return;
@@ -1,36 +1,54 @@
1
1
  import { existsSync, mkdirSync, watch } from "node:fs";
2
2
  import { basename, isAbsolute, join, resolve } from "node:path";
3
3
  import type { Endpoint, InstanceId, InstancePingResult } from "@ccmsg/protocol";
4
+ import { DEFAULT_HARNESS, type Harness, HARNESS, isHarness } from "../harness/index.ts";
4
5
  import {
5
6
  type InstanceEntry,
6
7
  loadShared,
7
8
  saveShared,
9
+ settingsFor,
8
10
  type SharedConfig,
9
11
  } from "../instance/config.ts";
10
12
  import { instanceIdentity } from "../instance/identity.ts";
11
13
  import { alive, lockHolder } from "../instance/lock.ts";
12
- import { type Env, type InstancePaths, resolvePaths } from "../instance/paths.ts";
14
+ import { type Env, type InstancePaths, resolvePaths, resolvePathsFor } from "../instance/paths.ts";
13
15
  import { prepareSocketDir } from "../instance/socket.ts";
14
16
  import { connect, greetAsUser } from "./control.ts";
15
17
  import { CommandError } from "./link.ts";
16
18
 
17
19
  /** What a config home has to be for an instance to answer for it.
18
20
  *
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 {
21
+ * The harness's own settings file is what says the directory is a config home
22
+ * rather than any directory somebody typed, so which file is looked for
23
+ * follows which harness the directory runs (§3.8). Checked where a directory
24
+ * is named `add` and `run` rather than at every use, so the mistake is
25
+ * caught when it is made. */
26
+ export function configHome(dir: string, harness: Harness = DEFAULT_HARNESS): string {
24
27
  const path = isAbsolute(dir) ? dir : resolve(dir);
25
- if (!existsSync(join(path, "settings.json"))) {
28
+ const marker = HARNESS[harness].marker;
29
+ if (!existsSync(join(path, marker))) {
26
30
  throw new CommandError(
27
31
  "not_found",
28
- `${path} は Claude Code の config home ではありません (settings.json がありません)`,
32
+ `${path} は ${harness} の config home ではありません (${marker} がありません)`,
29
33
  );
30
34
  }
31
35
  return path;
32
36
  }
33
37
 
38
+ /** Which harness a registered config home runs, as the shared file records it.
39
+ *
40
+ * Read from the same entry the instance itself will read (§8.2), so a command
41
+ * that has to know before anything is running — `run`, and the supervisor's
42
+ * own start — reaches the same answer the instance does. A directory the file
43
+ * does not list runs the default, which is what an unregistered `daemon run`
44
+ * is. */
45
+ export function harnessFor(env: Env, dir: string): Harness {
46
+ const path = isAbsolute(dir) ? dir : resolve(dir);
47
+ const settings = settingsFor(loadShared(resolvePaths(env).configFile), path);
48
+ const named = settings["harness"];
49
+ return isHarness(named) ? named : DEFAULT_HARNESS;
50
+ }
51
+
34
52
  /** One row of `daemon list`: which config home, and whether anything answers
35
53
  * for it right now. */
36
54
  export interface InstanceRow {
@@ -58,7 +76,7 @@ export interface Target {
58
76
  }
59
77
 
60
78
  export function targetFor(env: Env, dir: string): Target {
61
- return { dir, paths: resolvePaths({ ...env, CLAUDE_CONFIG_DIR: dir }) };
79
+ return { dir, paths: resolvePathsFor(dir, env) };
62
80
  }
63
81
 
64
82
  /** The config homes the shared file lists, in the order it lists them. */
@@ -68,15 +86,20 @@ export function registered(env: Env): Target[] {
68
86
  }
69
87
 
70
88
  /** 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);
89
+ * defaults until somebody edits its entry, so the entry starts empty — save
90
+ * for the harness, which is written down when it is not the default because it
91
+ * is the one setting the directory itself cannot be asked for (§3.8). */
92
+ export function add(env: Env, dir: string, harness: Harness = DEFAULT_HARNESS): InstanceRow {
93
+ const home = configHome(dir, harness);
74
94
  const file = resolvePaths(env).configFile;
75
95
  const shared = loadShared(file);
76
96
  if (shared.instances.some((entry) => entry.dir === home)) {
77
97
  throw new CommandError("file_exists", `${home} は既に登録されています`);
78
98
  }
79
- const entry: InstanceEntry = { dir: home, settings: {} };
99
+ const entry: InstanceEntry = {
100
+ dir: home,
101
+ settings: harness === DEFAULT_HARNESS ? {} : { harness },
102
+ };
80
103
  saveShared(file, { ...shared, instances: [...shared.instances, entry] });
81
104
  const target = targetFor(env, home);
82
105
  // The id is made here rather than at the first start, so that what `add`
@@ -203,9 +226,13 @@ export interface Child {
203
226
  export type SpawnInstance = (dir: string, env: Env) => Child;
204
227
 
205
228
  export const spawnInstance: SpawnInstance = (dir, env) => {
229
+ // The directory is an argument and not an environment variable: `daemon run`
230
+ // takes it from there and hands it to the instance by value, so which config
231
+ // home the child answers for cannot depend on which session the supervisor
232
+ // was started from (§3.8).
206
233
  const proc = Bun.spawn([process.execPath, ENTRY, "daemon", "run", dir], {
207
234
  stdio: ["ignore", "ignore", "ignore"],
208
- env: { ...env, CLAUDE_CONFIG_DIR: dir } as Record<string, string>,
235
+ env: { ...env } as Record<string, string>,
209
236
  });
210
237
  return {
211
238
  pid: proc.pid,
@@ -7,6 +7,7 @@ import {
7
7
  awaitSocket,
8
8
  type Child,
9
9
  configHome,
10
+ harnessFor,
10
11
  prepareFor,
11
12
  registered,
12
13
  rowFor,
@@ -304,7 +305,7 @@ export class Supervisor {
304
305
  * behind it is the state `add` exists to leave behind only when there is no
305
306
  * supervisor to tell. */
306
307
  async addOne(dir: string): Promise<StatusRow> {
307
- const home = configHome(dir);
308
+ const home = configHome(dir, harnessFor(this.#env, dir));
308
309
  if (this.#units.has(home)) {
309
310
  throw new CommandError("file_exists", `${home} は既に見ています`);
310
311
  }
@@ -0,0 +1,96 @@
1
+ /** Which harness a config home runs, and the few facts that differ with it.
2
+ *
3
+ * A harness is an attribute of an instance rather than of the contract: an
4
+ * instance answers for one config home (M6), that config home belongs to one
5
+ * program, and every difference below is a place where that program keeps
6
+ * something ccmsg reads. The protocol never names a harness, so nothing a
7
+ * client sees changes with this — what changes is which directory is walked,
8
+ * what says a session is there, and how route (a) is spoken.
9
+ *
10
+ * The facts live together because they are one table: adding a harness is
11
+ * filling a row, and a difference that has no row here is a difference nobody
12
+ * declared. */
13
+
14
+ export const HARNESSES = ["claude", "codex"] as const;
15
+ export type Harness = (typeof HARNESSES)[number];
16
+
17
+ /** What a config home runs when nothing says otherwise. Claude Code, because
18
+ * it is the harness ccmsg was read off and the one an unmarked config home in
19
+ * an existing setup belongs to. */
20
+ export const DEFAULT_HARNESS: Harness = "claude";
21
+
22
+ export interface HarnessFacts {
23
+ /** The file whose presence says a directory is this harness's config home
24
+ * rather than any directory somebody typed. Both harnesses keep their own
25
+ * settings in one, so this is the harness's own word for "mine". */
26
+ readonly marker: string;
27
+ /** The directory under the config home where transcripts are kept. Claude
28
+ * Code files them per working directory, Codex per date, so what this names
29
+ * is the root of the tree and not the directory a file is in. */
30
+ readonly transcripts: string;
31
+ /** The environment variable that names this harness's config home, which is
32
+ * what a session's own processes are run with. */
33
+ readonly homeEnv: string;
34
+ /** The variables that name the session a process is running inside, in the
35
+ * order they are believed. Set by the harness for the commands its session
36
+ * runs, and by nothing else — which is what makes them the answer to "whose
37
+ * session is this". */
38
+ readonly sessionEnv: readonly string[];
39
+ }
40
+
41
+ export const HARNESS: Record<Harness, HarnessFacts> = {
42
+ claude: {
43
+ marker: "settings.json",
44
+ transcripts: "projects",
45
+ homeEnv: "CLAUDE_CONFIG_DIR",
46
+ sessionEnv: ["CLAUDE_CODE_SESSION_ID"],
47
+ },
48
+ codex: {
49
+ marker: "config.toml",
50
+ transcripts: "sessions",
51
+ homeEnv: "CODEX_HOME",
52
+ sessionEnv: ["CODEX_THREAD_ID", "CODEX_SESSION_ID"],
53
+ },
54
+ };
55
+
56
+ /** The session a process is running inside, where its environment says so.
57
+ *
58
+ * Which harness is asked first cannot be the order two config-home variables
59
+ * happen to be listed in: a session of one harness started from a session of
60
+ * the other inherits the whole environment of its parent, so both homes are
61
+ * named at once and the outer one is named first. Measured, and not a corner:
62
+ * a Codex session started from a Claude Code session inherits
63
+ * `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_SESSION_ID` and the rest of it.
64
+ *
65
+ * So what decides is the session variables, and the config home follows from
66
+ * whichever harness claimed the process — one answer used both for "who am I"
67
+ * and for "which instance do I speak to", so the two can never disagree.
68
+ *
69
+ * Where more than one claims it, the order below decides, and it is not the
70
+ * order the harnesses are listed in. Claude Code exports its session id into
71
+ * everything the session starts, another harness included; Codex names its
72
+ * thread to the commands of its own turn. The narrower claim is the truer one,
73
+ * so it is asked first. The reverse nesting — a Claude Code session started
74
+ * from inside a Codex turn — reads as Codex. `--sid` overrides only the sid a
75
+ * command speaks as, and not which instance it speaks to (§3.8).
76
+ *
77
+ * A process no session runs inside — a person at a terminal, a supervisor —
78
+ * matches nothing here, and the caller falls back to what it would have done
79
+ * before being asked. */
80
+ const CLAIM_ORDER: readonly Harness[] = ["codex", "claude"];
81
+
82
+ export function currentSession(
83
+ env: Record<string, string | undefined>,
84
+ ): { harness: Harness; sid: string } | undefined {
85
+ for (const harness of CLAIM_ORDER) {
86
+ for (const variable of HARNESS[harness].sessionEnv) {
87
+ const sid = env[variable];
88
+ if (sid !== undefined && sid !== "") return { harness, sid };
89
+ }
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ export function isHarness(value: unknown): value is Harness {
95
+ return HARNESSES.includes(value as Harness);
96
+ }
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute } from "node:path";
3
3
  import type { Endpoint } from "@ccmsg/protocol";
4
+ import { DEFAULT_HARNESS, type Harness, HARNESSES, isHarness } from "../harness/index.ts";
4
5
  import { parseCidr } from "./client.ts";
5
6
 
6
7
  /** Where the instance accepts WebSocket connections, and from whom.
@@ -91,6 +92,13 @@ export interface UpstreamConfig {
91
92
  }
92
93
 
93
94
  export interface InstanceConfig {
95
+ /** Which harness this config home runs (§3.8).
96
+ *
97
+ * A setting rather than something discovered, because it decides where the
98
+ * instance looks before there is anything there to look at: an empty config
99
+ * home says nothing about the program it belongs to, and an instance that
100
+ * guessed would walk the wrong tree for the whole of its first session. */
101
+ readonly harness: Harness;
94
102
  /** Every mesh endpoint, this instance's own among them (§7.1). The same list
95
103
  * goes to every instance and names none of them in particular: which entry is
96
104
  * this one is settled at startup by the probe, so one file can be copied to
@@ -133,6 +141,7 @@ export class ConfigError extends Error {
133
141
  * one that is there and unreadable states something wrong — only the second is
134
142
  * the fail-fast case. */
135
143
  export const DEFAULT_CONFIG: InstanceConfig = {
144
+ harness: DEFAULT_HARNESS,
136
145
  peers: [],
137
146
  upstream: {},
138
147
  direct_delivery: true,
@@ -233,6 +242,7 @@ export function settingsFor(shared: SharedConfig, dir: string): Record<string, u
233
242
  /** One instance's settings, read at the shape the instance uses them. */
234
243
  export function parseConfig(file: string, fields: Record<string, unknown>): InstanceConfig {
235
244
  return {
245
+ harness: harnessOf(file, fields["harness"]),
236
246
  peers: peersOf(file, fields["peers"]),
237
247
  ...(fields["entry"] === undefined ? {} : { entry: entryOf(file, fields["entry"]) }),
238
248
  upstream: upstreamOf(file, fields["upstream"]),
@@ -246,6 +256,14 @@ export function parseConfig(file: string, fields: Record<string, unknown>): Inst
246
256
  };
247
257
  }
248
258
 
259
+ function harnessOf(file: string, raw: unknown): Harness {
260
+ if (raw === undefined) return DEFAULT_HARNESS;
261
+ if (!isHarness(raw)) {
262
+ throw new ConfigError(file, `harness must be one of ${HARNESSES.join(", ")}`);
263
+ }
264
+ return raw;
265
+ }
266
+
249
267
  function flagOf(file: string, at: string, raw: unknown, fallback: boolean): boolean {
250
268
  if (raw === undefined) return fallback;
251
269
  if (typeof raw !== "boolean") throw new ConfigError(file, `${at} must be true or false`);
@@ -268,6 +286,21 @@ function endpointOf(file: string, at: string, raw: unknown): Endpoint {
268
286
  return raw as Endpoint;
269
287
  }
270
288
 
289
+ /** `terminal_gateway`'s shape, matched to the contract's `HelloResult` so a
290
+ * value this instance would refuse to report is refused here instead, at
291
+ * startup, rather than on the first `hello`. */
292
+ const TERMINAL_GATEWAY = /^https?:\/\/[^/?#\s]+(\/[^?#\s]*[^/?#\s])?$/;
293
+
294
+ function terminalGatewayOf(file: string, raw: string): string {
295
+ if (!TERMINAL_GATEWAY.test(raw)) {
296
+ throw new ConfigError(
297
+ file,
298
+ `upstream.terminal_gateway must be an http:// or https:// base URL with no trailing slash, got ${raw}`,
299
+ );
300
+ }
301
+ return raw;
302
+ }
303
+
271
304
  function peersOf(file: string, raw: unknown): readonly Endpoint[] {
272
305
  if (raw === undefined) return [];
273
306
  if (!Array.isArray(raw)) throw new ConfigError(file, "peers must be an array of endpoint URLs");
@@ -316,6 +349,9 @@ function upstreamOf(file: string, raw: unknown): UpstreamConfig {
316
349
  }
317
350
  config[name] = value;
318
351
  }
352
+ if (config["terminal_gateway"] !== undefined) {
353
+ config["terminal_gateway"] = terminalGatewayOf(file, config["terminal_gateway"]);
354
+ }
319
355
  const launcher = fields["launcher"];
320
356
  return {
321
357
  ...(config as UpstreamConfig),