@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.
@@ -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 = resolvePaths(env);
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,
@@ -455,6 +469,9 @@ export class Instance {
455
469
  transcript: this.#transcripts,
456
470
  gateway: this.#gateway,
457
471
  terminals: hostTerminalReader(),
472
+ ...(config.upstream.terminal_gateway === undefined
473
+ ? {}
474
+ : { terminalGateway: config.upstream.terminal_gateway }),
458
475
  log: (msg, fields) => {
459
476
  this.log.write(msg, fields);
460
477
  },
@@ -490,9 +507,15 @@ export class Instance {
490
507
 
491
508
  const inbox = new Inbox(inboxPath(paths.stateDir));
492
509
  inbox.load();
493
- this.#direct = config.direct_delivery
494
- ? new ClaudeCodeSocketRoute({ configHome: paths.configHome })
495
- : new DisabledDirectRoute();
510
+ // Route (a) is the harness's own way in (§4.1): Claude Code's messaging
511
+ // socket, Codex's thread queue. Which one an instance speaks follows the
512
+ // config home it answers for (§3.8), and the flag turns the route off for
513
+ // either.
514
+ this.#direct = !config.direct_delivery
515
+ ? new DisabledDirectRoute()
516
+ : config.harness === "codex"
517
+ ? new CodexQueueRoute({ configHome: paths.configHome })
518
+ : new ClaudeCodeSocketRoute({ configHome: paths.configHome });
496
519
  this.#delivery = new Delivery({
497
520
  self: this.self,
498
521
  sessions: this.#sessions,
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, isAbsolute, join } from "node:path";
4
+ import { currentSession, HARNESS } from "../harness/index.ts";
4
5
 
5
6
  /** Every path one instance uses, decided in one place (daemon-v2 §8.1).
6
7
  *
@@ -64,12 +65,27 @@ export type Env = Record<string, string | undefined>;
64
65
 
65
66
  /** The config home this process belongs to.
66
67
  *
67
- * `CLAUDE_CONFIG_DIR` is what the harness itself reads, so a session and the
68
- * instance it talks to agree on which one they mean without ccmsg naming it
69
- * separately. Nothing searches for another one (M6). */
68
+ * The session a process runs inside decides, where it says so: the harness
69
+ * that claimed it names its own config home, and that is the instance this
70
+ * process belongs to. Reading the config-home variables in a fixed order
71
+ * instead would send a Codex session's commands to the Claude Code instance,
72
+ * because a session started from inside another one inherits its whole
73
+ * environment and so names both homes at once (§3.8).
74
+ *
75
+ * With no session claiming the process — a person at a terminal — each
76
+ * harness's own variable is read in turn, and Claude Code's home is the
77
+ * default, because that is what an existing setup exports and what an
78
+ * unmarked config home is. Nothing searches for another one (M6). */
70
79
  export function resolveConfigHome(env: Env = process.env): string {
71
- const named = env["CLAUDE_CONFIG_DIR"];
72
- if (named !== undefined && named !== "" && isAbsolute(named)) return named;
80
+ const inside = currentSession(env);
81
+ if (inside !== undefined) {
82
+ const named = env[HARNESS[inside.harness].homeEnv];
83
+ if (named !== undefined && named !== "" && isAbsolute(named)) return named;
84
+ }
85
+ for (const facts of Object.values(HARNESS)) {
86
+ const named = env[facts.homeEnv];
87
+ if (named !== undefined && named !== "" && isAbsolute(named)) return named;
88
+ }
73
89
  return join(home(env), ".claude");
74
90
  }
75
91
 
@@ -82,7 +98,17 @@ export function resolveConfigHome(env: Env = process.env): string {
82
98
  * the state so a temporary directory sweep cannot take the socket out from
83
99
  * under a running instance. */
84
100
  export function resolvePaths(env: Env = process.env): InstancePaths {
85
- const configHome = resolveConfigHome(env);
101
+ return resolvePathsFor(resolveConfigHome(env), env);
102
+ }
103
+
104
+ /** Everything one instance uses, for a config home the caller already knows.
105
+ *
106
+ * The one to call wherever the config home is decided rather than discovered —
107
+ * a command naming a directory, an install naming an agent's own home. Going
108
+ * through the environment instead would have that answer re-derived from
109
+ * whichever session the process happens to be running inside (§3.8), and a
110
+ * command that named a directory would silently act on another one. */
111
+ export function resolvePathsFor(configHome: string, env: Env = process.env): InstancePaths {
86
112
  const key = instanceKey(configHome);
87
113
  const configDir = resolveConfigDir(env);
88
114
  const stateDir = appDir(env, "CCMSG_STATE_DIR", "XDG_STATE_HOME", [".local", "state"], key);
@@ -3,6 +3,7 @@ import { chmodSync, unlinkSync } from "node:fs";
3
3
  import { readdir, readFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import { type InboxMessage, renderDirectDelivery, type Sid } from "@ccmsg/protocol";
6
+ import { HARNESS, HARNESSES } from "../harness/index.ts";
6
7
 
7
8
  /** What route (a) answered (§4.1).
8
9
  *
@@ -327,6 +328,121 @@ export class ClaudeCodeSocketRoute implements DirectRoute {
327
328
  }
328
329
  }
329
330
 
331
+ /** How the Codex CLI is run, and what it answered. Named so a test can watch
332
+ * what would be run without a thread of anybody's being written to. */
333
+ export type RunCodex = (args: readonly string[], env: Env) => Promise<{ code: number }>;
334
+
335
+ type Env = Record<string, string>;
336
+
337
+ /** What the CLI must not inherit from the daemon.
338
+ *
339
+ * A daemon carries whatever environment it was started in, which on a host
340
+ * where somebody works in Claude Code names that config home and that session.
341
+ * Passed through, they would tell the Codex CLI about a config home this
342
+ * instance is not about and a session that is not the one being written to.
343
+ * The home this route means is named explicitly, and the rest is dropped
344
+ * (§3.8). */
345
+ const DROPPED = HARNESSES.filter((harness) => harness !== "codex").flatMap((harness) => [
346
+ HARNESS[harness].homeEnv,
347
+ ...HARNESS[harness].sessionEnv,
348
+ ]);
349
+
350
+ /** How long the CLI has to answer before the send is taken as not having gone
351
+ * this way.
352
+ *
353
+ * `codex queue` is a request to a thread store and answers at once: against a
354
+ * thread nobody has, it failed with the store's own error immediately, in a
355
+ * config home that had never been used and a directory Codex had never been
356
+ * told to trust (0.154.0, standard input closed, no terminal). Neither the
357
+ * update notice nor the directory-trust question is asked on this path — both
358
+ * belong to the interactive interface.
359
+ *
360
+ * The budget is here for what is not being predicted: a child that never
361
+ * answers would hold `message_send` open for as long as it lived, and route
362
+ * (b) exists exactly so a route that does not come through costs a message
363
+ * nothing (§4.1). It is generous next to a call that has been measured to
364
+ * return at once. */
365
+ export const QUEUE_MS = 10_000;
366
+
367
+ const runCodex: RunCodex = async (args, env) => {
368
+ let spawned: Bun.Subprocess<"ignore", "ignore", "ignore">;
369
+ const inherited = { ...process.env } as Record<string, string | undefined>;
370
+ for (const name of DROPPED) delete inherited[name];
371
+ try {
372
+ spawned = Bun.spawn({
373
+ cmd: ["codex", ...args],
374
+ env: { ...inherited, ...env } as Record<string, string>,
375
+ // Nothing is read from us: a child holding the daemon's own standard
376
+ // input could wait on somebody who is not there.
377
+ stdin: "ignore",
378
+ stdout: "ignore",
379
+ stderr: "ignore",
380
+ });
381
+ } catch {
382
+ // No `codex` on `PATH`, which is the same as the route not applying: the
383
+ // message goes by route (b) and nothing about it is lost (§4.1).
384
+ return { code: 127 };
385
+ }
386
+ // The timer is held so it can be cleared: a send that answered in a
387
+ // millisecond must not leave the loop something to wake up for ten seconds
388
+ // later, which is what an instance shutting down would then wait on.
389
+ const late = Promise.withResolvers<"late">();
390
+ const timer = setTimeout(() => late.resolve("late"), QUEUE_MS);
391
+ try {
392
+ const finished = await Promise.race([spawned.exited, late.promise]);
393
+ if (finished === "late") {
394
+ spawned.kill();
395
+ return { code: 124 };
396
+ }
397
+ return { code: finished };
398
+ } finally {
399
+ clearTimeout(timer);
400
+ }
401
+ };
402
+
403
+ export interface QueueRouteOptions {
404
+ /** The one config home this instance answers for (M6). It is named to the
405
+ * CLI rather than inherited, because the daemon's own environment says which
406
+ * config home it serves in its own words and Codex reads only its own. */
407
+ readonly configHome: string;
408
+ readonly run?: RunCodex;
409
+ }
410
+
411
+ /** Route (a) against a Codex thread's queue (§4.1).
412
+ *
413
+ * Codex has no socket a message can be written to: what it has is a queue per
414
+ * thread, held by the app-server the thread belongs to, and `codex queue` is
415
+ * the way in that ccmsg does not have to reimplement. A queued message is
416
+ * delivered when the thread is idle, after the current turn when it is busy,
417
+ * and on resume when it is not loaded (codex-cli 0.153.4).
418
+ *
419
+ * So `delivered` here means the queue took it, which is the same promise route
420
+ * (a) makes on the other harness: the receiving session holds the message, and
421
+ * nothing says the model has read it. There is no receipt channel, so there is
422
+ * no `refused` — a thread that turns a message away does so where nothing
423
+ * reports back, and a queue that would not take it fails the command and falls
424
+ * to route (b). */
425
+ export class CodexQueueRoute implements DirectRoute {
426
+ readonly #run: RunCodex;
427
+
428
+ readonly #env: Env;
429
+
430
+ constructor(options: QueueRouteOptions) {
431
+ this.#run = options.run ?? runCodex;
432
+ this.#env = { CODEX_HOME: options.configHome };
433
+ }
434
+
435
+ async send(sid: Sid, message: InboxMessage): Promise<DirectOutcome> {
436
+ const { code } = await this.#run(
437
+ ["queue", "--thread", sid, "--message", renderDirectDelivery(message)],
438
+ this.#env,
439
+ );
440
+ return code === 0 ? "delivered" : "unavailable";
441
+ }
442
+
443
+ close(): void {}
444
+ }
445
+
330
446
  /** The two lines one send writes: the auth frame the harness's own senders
331
447
  * write first, then the message.
332
448
  *
@@ -1,3 +1,4 @@
1
+ import { DESCRIPTION, SKILL } from "./skill.ts";
1
2
  /** The plugin ccmsg hands to Claude Code, as its files.
2
3
  *
3
4
  * Written out rather than shipped as a directory in the package: the daemon,
@@ -17,8 +18,6 @@ export const PLUGIN_NAME = "ccmsg";
17
18
  export const MARKETPLACE_NAME = "ccmsg";
18
19
  export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
19
20
 
20
- const DESCRIPTION = "別の Claude Code セッションと行き来するメッセージ";
21
-
22
21
  /** How a hook reaches `ccmsg`.
23
22
  *
24
23
  * Through `PATH`, not through the plugin's own directory: the binary is
@@ -36,76 +35,6 @@ function throughPath(command: string): string {
36
35
  * both give up on their own when there is no instance behind it. */
37
36
  const HOOK_TIMEOUT_S = 5;
38
37
 
39
- const SKILL = `---
40
- name: ccmsg
41
- description: 別の Claude Code セッションへ声をかける・届いたメッセージに返す・見ている人へ知らせる時に使う。
42
- ---
43
-
44
- # ccmsg
45
-
46
- 同じ人が動かしている別のセッションと、メッセージをやり取りする。
47
-
48
- ## 届いたメッセージに返す
49
-
50
- メッセージは \`<cross-session-message>\` の封筒で届き、本文の最後に返信の一行が付いている。
51
-
52
- \`\`\`
53
- Reply with: ccmsg reply <mid> --to <sid> <text>
54
- \`\`\`
55
-
56
- **その行をそのまま実行する。** 宛先も、どのメッセージへの返事かも、その行が持っている。
57
- 自分で \`post\` を組み立て直さない。\`--to\` の無い行は人からのメッセージで、返事は通知として届く。
58
-
59
- ## 自分から声をかける
60
-
61
- \`\`\`
62
- ccmsg post <sid> <text>
63
- \`\`\`
64
-
65
- 相手の \`<sid>\` は、届いた封筒の \`ccmsg-from\` の値。
66
-
67
- ## 相手を探す
68
-
69
- まだ話したことのない相手の \`<sid>\` は、繋がっているセッションの一覧から探す。
70
-
71
- \`\`\`
72
- ccmsg peers この instance が知っているセッション
73
- ccmsg peers --all 他ホストの instance が知っている分も含める
74
- \`\`\`
75
-
76
- 答えは instance ごとの JSON。\`peers[]\` が今繋がっているセッション、\`last_live[]\` が
77
- 居なくなったセッションで、各行の \`repo\` / \`ws\` / \`branch\` / \`title\` で見分けて
78
- \`sid\` を取る。\`send_message\` が \`true\` の相手には harness 自身の機能でも届く。
79
-
80
- ## 相手セッションの扱い
81
-
82
- 相手は基本、自分にとってのサブエージェントだと思えばよい。対等な会議を開く場ではないので、
83
- 冒頭の挨拶・賛辞・締めの社交辞令を省き、用件だけを 1〜3 文で送る。
84
-
85
- やり取りの中身を人へリレーしない。人は全セッションを直接見ているので、相手の完了報告や
86
- 根拠をこちらで要約し直しても情報は増えず、時間とコンテキストだけが減る。人に言うのは
87
- 自セッション目線の事実 (何を頼んだ・何が返り・その結果こちらが何をしたか) だけ。
88
-
89
- ## 見ている人へ知らせる
90
-
91
- \`\`\`
92
- ccmsg notify <text> 一行知らせる (保持されない、返事も来ない)
93
- ccmsg say <text> 声に出して知らせる
94
- \`\`\`
95
-
96
- 手が空いた・判断を仰ぎたい・長い作業が終わった、を人に伝えるときに使う。
97
- セッション同士のやり取りには使わない。
98
-
99
- ## これから終わるとき
100
-
101
- \`\`\`
102
- ccmsg stopping --reason <理由>
103
- \`\`\`
104
-
105
- 以後このセッションは「一時停止」として扱われ、宛てられたメッセージは戻ってきたときに渡される。
106
- セッション終了時には自動で伝わるので、途中で自分から言う必要はない。
107
- `;
108
-
109
38
  const HOOKS = {
110
39
  hooks: {
111
40
  SessionStart: [