@ccmsg/cli 0.2.13 → 0.3.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/package.json +1 -1
- package/src/cli.ts +96 -29
- package/src/daemon/registry.ts +41 -14
- package/src/daemon/supervise.ts +2 -1
- package/src/harness/index.ts +96 -0
- package/src/instance/config.ts +18 -0
- package/src/instance/instance.ts +25 -5
- package/src/instance/paths.ts +32 -6
- package/src/messaging/direct.ts +116 -0
- package/src/plugin/claude.ts +1 -72
- package/src/plugin/codex.ts +332 -0
- package/src/plugin/index.ts +7 -5
- package/src/plugin/install.ts +41 -161
- package/src/plugin/receipt.ts +202 -0
- package/src/plugin/skill.ts +84 -0
- package/src/sessions/harness.ts +166 -31
- package/src/sessions/registry.ts +75 -53
- package/src/sessions/search.ts +4 -4
- package/src/transcript/files.ts +103 -22
- package/src/transcript/fold.ts +72 -1
package/package.json
CHANGED
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 {
|
|
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
|
-
|
|
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: [
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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>",
|
|
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
|
-
["
|
|
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 [["
|
|
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
|
|
475
|
-
const
|
|
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(
|
|
519
|
-
|
|
520
|
-
const
|
|
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") ??
|
|
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 ??
|
|
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
|
|
1007
|
+
if (agent !== undefined && !isHarness(agent)) {
|
|
950
1008
|
throw new CommandError(
|
|
951
1009
|
"invalid_args",
|
|
952
|
-
`${agent}
|
|
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
|
-
|
|
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 ??
|
|
1079
|
+
const sid = named ?? ownSid();
|
|
1013
1080
|
if (sid === undefined || sid === "") {
|
|
1014
1081
|
throw new CommandError(
|
|
1015
1082
|
"invalid_args",
|
|
1016
|
-
|
|
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 =
|
|
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;
|
package/src/daemon/registry.ts
CHANGED
|
@@ -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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
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
|
-
|
|
28
|
+
const marker = HARNESS[harness].marker;
|
|
29
|
+
if (!existsSync(join(path, marker))) {
|
|
26
30
|
throw new CommandError(
|
|
27
31
|
"not_found",
|
|
28
|
-
`${path} は
|
|
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:
|
|
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
|
-
|
|
73
|
-
|
|
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 = {
|
|
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
|
|
235
|
+
env: { ...env } as Record<string, string>,
|
|
209
236
|
});
|
|
210
237
|
return {
|
|
211
238
|
pid: proc.pid,
|
package/src/daemon/supervise.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/instance/config.ts
CHANGED
|
@@ -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`);
|
package/src/instance/instance.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "../files/index.ts";
|
|
31
31
|
import {
|
|
32
32
|
ClaudeCodeSocketRoute,
|
|
33
|
+
CodexQueueRoute,
|
|
33
34
|
Delivery,
|
|
34
35
|
DisabledDirectRoute,
|
|
35
36
|
type DirectRoute,
|
|
@@ -94,11 +95,21 @@ import { acquireLock, type Held, isHeldByUs, type Lock } from "./lock.ts";
|
|
|
94
95
|
import { Log } from "./log.ts";
|
|
95
96
|
import { prepareSocketDir, publishSocket, sweepOrphanSockets } from "./socket.ts";
|
|
96
97
|
import { instanceIdentity } from "./identity.ts";
|
|
97
|
-
import { type Env, type InstancePaths, resolvePaths } from "./paths.ts";
|
|
98
|
+
import { type Env, type InstancePaths, resolvePaths, resolvePathsFor } from "./paths.ts";
|
|
98
99
|
import { VERSION } from "../version.ts";
|
|
99
100
|
|
|
100
101
|
export interface StartOptions {
|
|
101
102
|
readonly env?: Env;
|
|
103
|
+
/** The config home this instance answers for (M6).
|
|
104
|
+
*
|
|
105
|
+
* Passed by value rather than through the environment, because the
|
|
106
|
+
* environment is read for a different question: which session the process
|
|
107
|
+
* runs inside, and therefore which config home *that* means (§3.8). A
|
|
108
|
+
* `daemon run <dir>` started from inside a session of another harness would
|
|
109
|
+
* otherwise answer for the config home of whoever started it. Absent means
|
|
110
|
+
* the environment decides, which is what a process nobody named a directory
|
|
111
|
+
* to is asking for. */
|
|
112
|
+
readonly configHome?: string;
|
|
102
113
|
/** Mirror the log to stderr. A foreground run wants it; a test does not. */
|
|
103
114
|
readonly echoLog?: boolean;
|
|
104
115
|
/** Overrides the confirmation poll of the sessions watch, for tests. */
|
|
@@ -150,7 +161,8 @@ export function isRunning(outcome: StartOutcome): outcome is Instance {
|
|
|
150
161
|
export async function start(options: StartOptions = {}): Promise<StartOutcome> {
|
|
151
162
|
// 1. paths, and the directory the rest of them live in
|
|
152
163
|
const env = options.env ?? process.env;
|
|
153
|
-
const paths =
|
|
164
|
+
const paths =
|
|
165
|
+
options.configHome === undefined ? resolvePaths(env) : resolvePathsFor(options.configHome, env);
|
|
154
166
|
mkdirSync(paths.stateDir, { recursive: true });
|
|
155
167
|
|
|
156
168
|
// 2. the single instance. A previous run's file with nobody behind it is
|
|
@@ -420,6 +432,7 @@ export class Instance {
|
|
|
420
432
|
// here, so a sid resolves to the same file whichever way it is reached.
|
|
421
433
|
const transcriptFiles = new TranscriptFiles({
|
|
422
434
|
configHome: paths.configHome,
|
|
435
|
+
harness: config.harness,
|
|
423
436
|
announced: (sid) => this.#sessions.transcriptPath(sid),
|
|
424
437
|
});
|
|
425
438
|
|
|
@@ -442,6 +455,7 @@ export class Instance {
|
|
|
442
455
|
// 6. `last_live` and the inbox, read as the domains are constructed.
|
|
443
456
|
this.#sessions = new Sessions({
|
|
444
457
|
self: this.self,
|
|
458
|
+
harness: config.harness,
|
|
445
459
|
...(this.#mesh === undefined ? {} : { endpoint: this.#mesh.self }),
|
|
446
460
|
authExpiresAt: (conn) => this.#auth.expiresAt(conn),
|
|
447
461
|
configHome: paths.configHome,
|
|
@@ -490,9 +504,15 @@ export class Instance {
|
|
|
490
504
|
|
|
491
505
|
const inbox = new Inbox(inboxPath(paths.stateDir));
|
|
492
506
|
inbox.load();
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
507
|
+
// Route (a) is the harness's own way in (§4.1): Claude Code's messaging
|
|
508
|
+
// socket, Codex's thread queue. Which one an instance speaks follows the
|
|
509
|
+
// config home it answers for (§3.8), and the flag turns the route off for
|
|
510
|
+
// either.
|
|
511
|
+
this.#direct = !config.direct_delivery
|
|
512
|
+
? new DisabledDirectRoute()
|
|
513
|
+
: config.harness === "codex"
|
|
514
|
+
? new CodexQueueRoute({ configHome: paths.configHome })
|
|
515
|
+
: new ClaudeCodeSocketRoute({ configHome: paths.configHome });
|
|
496
516
|
this.#delivery = new Delivery({
|
|
497
517
|
self: this.self,
|
|
498
518
|
sessions: this.#sessions,
|