@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.
@@ -0,0 +1,202 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { HARNESSES } from "../harness/index.ts";
4
+ import type { InstancePaths } from "../instance/index.ts";
5
+
6
+ /** The agents ccmsg can install a plugin for, which are the harnesses it
7
+ * speaks to: what is installed is how a session of that harness reaches this
8
+ * instance, so there is one plugin per harness and no third thing to name. */
9
+ export const AGENTS = HARNESSES;
10
+ export type Agent = (typeof AGENTS)[number];
11
+
12
+ /** How an agent's own CLI is run. The environment is inherited, which is how
13
+ * the install lands in the config home this instance answers for and not in
14
+ * another one (M6). Named so a test can watch what would be run without a
15
+ * config home of a person's being touched. */
16
+ export type Run = (args: readonly string[]) => Promise<Ran>;
17
+
18
+ export interface Ran {
19
+ readonly code: number;
20
+ readonly stdout: string;
21
+ readonly stderr: string;
22
+ }
23
+
24
+ /** Why one of these commands stopped where it did: the agent command that was
25
+ * refused, and what it said. */
26
+ export interface Refusal {
27
+ readonly command: readonly string[];
28
+ readonly code: number;
29
+ readonly said: string;
30
+ }
31
+
32
+ /** A command the agent refused, as the report carries it: what was run, what it
33
+ * exited with, and its first line of complaint. The exit code is stated apart
34
+ * from the words because a command that said nothing still failed. */
35
+ export function refusal(binary: string, command: readonly string[], ran: Ran): Refusal {
36
+ const said = `${ran.stderr}${ran.stdout}`.trim();
37
+ return { command: [binary, ...command], code: ran.code, said: said.split("\n")[0] ?? "" };
38
+ }
39
+
40
+ /** What the three commands answer with.
41
+ *
42
+ * Fields rather than sentences: these commands are read by whatever runs them
43
+ * as much as by a person, and a line of prose is something a caller has to
44
+ * parse back into the facts it was built from. The words a person wants are in
45
+ * `--help`; what is here is what was found. */
46
+ export interface Report {
47
+ readonly agent: Agent;
48
+ /** Whether the command did everything it set out to do. */
49
+ readonly ok: boolean;
50
+ /** The step that stopped it. Absent while `ok`. */
51
+ readonly refused?: Refusal;
52
+ /** What the agent still needs a person to do before what was installed takes
53
+ * effect. Absent where nothing does. */
54
+ readonly needs?: string;
55
+ }
56
+
57
+ export interface InstallReport extends Report {
58
+ readonly version: string;
59
+ readonly config_home: string;
60
+ readonly root: string;
61
+ /** The files laid down, by their path under `root`. */
62
+ readonly files: readonly string[];
63
+ /** The files laid down elsewhere, by their whole path. What an agent reads
64
+ * out of its own config home rather than out of a plugin's directory goes
65
+ * here, so uninstall takes back exactly what was put there. */
66
+ readonly placed?: readonly string[];
67
+ readonly marketplace?: { readonly name: string; readonly registered: boolean };
68
+ readonly plugin?: {
69
+ readonly id: string;
70
+ readonly installed: boolean;
71
+ /** Whether a copy of the same id was taken out first, which is what makes
72
+ * a repeated install run what was just laid down. */
73
+ readonly replaced: boolean;
74
+ };
75
+ /** The agent commands that were run, as they were run. */
76
+ readonly commands: readonly (readonly string[])[];
77
+ }
78
+
79
+ export interface StatusReport extends Report {
80
+ /** Where the receipt is. Absent when ccmsg installed nothing here, which is
81
+ * what makes every field below it absent too. */
82
+ readonly receipt?: string;
83
+ readonly installed_at?: string;
84
+ /** What the receipt says was installed. */
85
+ readonly version?: string;
86
+ readonly config_home?: string;
87
+ readonly root?: string;
88
+ /** The receipt's files, counted against what is under `root` now. */
89
+ readonly files?: {
90
+ readonly expected: number;
91
+ readonly present: number;
92
+ readonly missing: readonly string[];
93
+ };
94
+ readonly marketplace?: {
95
+ readonly name?: string;
96
+ /** Whether the agent has it. Absent when the agent could not be asked,
97
+ * which is a different thing from it not being registered. */
98
+ readonly registered?: boolean;
99
+ /** Where the agent thinks it points, when that is not where the receipt
100
+ * put it. */
101
+ readonly points_at?: string;
102
+ };
103
+ readonly plugin?: {
104
+ readonly id?: string;
105
+ /** What the agent reports having, and whether it has it switched on.
106
+ * Present with no `expected_version` beside it means something other than
107
+ * ccmsg installed it. */
108
+ readonly installed_version?: string;
109
+ readonly enabled?: boolean;
110
+ /** What the receipt says should be there. */
111
+ readonly expected_version?: string;
112
+ };
113
+ /** Whether the agent's hooks are switched on at all, where that is a setting
114
+ * of the agent rather than of the plugin. Absent when it could not be asked. */
115
+ readonly hooks_enabled?: boolean;
116
+ }
117
+
118
+ export interface UninstallReport extends Report {
119
+ readonly receipt?: string;
120
+ /** What was actually taken back out. A step the receipt does not name was
121
+ * never taken, so it is not undone and does not appear here. */
122
+ readonly removed: {
123
+ readonly plugin?: string;
124
+ readonly marketplace?: string;
125
+ readonly root?: string;
126
+ readonly placed?: readonly string[];
127
+ };
128
+ }
129
+
130
+ export type Outcome = InstallReport | StatusReport | UninstallReport;
131
+
132
+ /** What one install did, so that uninstall can undo exactly that.
133
+ *
134
+ * Everything reversible is written down before the next step is taken: the
135
+ * files that were laid down, the commands that were run against the agent, and
136
+ * the id the agent now knows the plugin by. Undoing reads this and nothing
137
+ * else — an install that half-finished leaves a receipt for the half that
138
+ * happened, and a plugin somebody else installed is not in it and is left
139
+ * alone. */
140
+ export interface Receipt {
141
+ readonly agent: Agent;
142
+ readonly version: string;
143
+ readonly installed_at: string;
144
+ /** The config home the agent was asked to install into. */
145
+ readonly config_home: string;
146
+ /** Where the plugin's own files were laid down. */
147
+ readonly root: string;
148
+ /** Their paths under that root, in the order they were written. */
149
+ readonly files: readonly string[];
150
+ /** Whole paths written outside that root, in the order they were written. */
151
+ readonly placed?: readonly string[];
152
+ /** The agent commands that were run, as they were run. */
153
+ readonly commands: readonly (readonly string[])[];
154
+ readonly marketplace?: string;
155
+ readonly plugin_id?: string;
156
+ }
157
+
158
+ export function rootFor(paths: InstancePaths, agent: Agent): string {
159
+ return join(paths.pluginsDir, agent);
160
+ }
161
+
162
+ export function receiptFile(paths: InstancePaths, agent: Agent): string {
163
+ return join(paths.pluginsDir, `${agent}.receipt.json`);
164
+ }
165
+
166
+ export async function readReceipt(
167
+ paths: InstancePaths,
168
+ agent: Agent,
169
+ ): Promise<Receipt | undefined> {
170
+ try {
171
+ const parsed: unknown = JSON.parse(await readFile(receiptFile(paths, agent), "utf8"));
172
+ return typeof parsed === "object" && parsed !== null ? (parsed as Receipt) : undefined;
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ export async function writeReceipt(paths: InstancePaths, receipt: Receipt): Promise<void> {
179
+ const file = receiptFile(paths, receipt.agent);
180
+ await mkdir(dirname(file), { recursive: true });
181
+ await writeFile(file, `${JSON.stringify(receipt, null, 2)}\n`);
182
+ }
183
+
184
+ /** Lay a set of files down, each under the root, in the order given. A string
185
+ * is written as it is; anything else is the content of a JSON file, so the
186
+ * definitions state shapes rather than text and one place turns a value into
187
+ * bytes. */
188
+ export async function place(
189
+ root: string,
190
+ files: ReadonlyMap<string, string | object>,
191
+ mode?: number,
192
+ ): Promise<void> {
193
+ for (const [path, content] of files) {
194
+ const file = join(root, path);
195
+ await mkdir(dirname(file), { recursive: true });
196
+ await writeFile(
197
+ file,
198
+ typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`,
199
+ mode === undefined ? undefined : { mode },
200
+ );
201
+ }
202
+ }
@@ -0,0 +1,84 @@
1
+ /** What a session is told about ccmsg, and how ccmsg is described where an
2
+ * agent lists what it has installed.
3
+ *
4
+ * One text for every harness: what a session has to know is how to answer a
5
+ * message and how to find somebody to send one to, and neither depends on
6
+ * which program the session runs in. The plugin around it differs — where the
7
+ * file goes, how a hook is declared — and the words do not.
8
+ *
9
+ * Written out rather than shipped as a file in the package: the daemon, the
10
+ * CLI and the plugin are one release, and generating the plugin from the
11
+ * running binary is what keeps the three from drifting into three versions of
12
+ * "what ccmsg is". */
13
+
14
+ export const DESCRIPTION = "別のセッションと行き来するメッセージ";
15
+
16
+ export const SKILL = `---
17
+ name: ccmsg
18
+ description: 別のセッションへ声をかける・届いたメッセージに返す・見ている人へ知らせる時に使う。
19
+ ---
20
+
21
+ # ccmsg
22
+
23
+ 同じ人が動かしている別のセッションと、メッセージをやり取りする。相手が別のハーネスで動いていても同じ手順で届く。
24
+
25
+ ## 届いたメッセージに返す
26
+
27
+ メッセージは \`<cross-session-message>\` の封筒で届き、本文の最後に返信の一行が付いている。
28
+
29
+ \`\`\`
30
+ Reply with: ccmsg reply <mid> --to <sid> <text>
31
+ \`\`\`
32
+
33
+ **その行をそのまま実行する。** 宛先も、どのメッセージへの返事かも、その行が持っている。
34
+ 自分で \`post\` を組み立て直さない。\`--to\` の無い行は人からのメッセージで、返事は通知として届く。
35
+
36
+ ## 自分から声をかける
37
+
38
+ \`\`\`
39
+ ccmsg post <sid> <text>
40
+ \`\`\`
41
+
42
+ 相手の \`<sid>\` は、届いた封筒の \`ccmsg-from\` の値。
43
+
44
+ ## 相手を探す
45
+
46
+ まだ話したことのない相手の \`<sid>\` は、繋がっているセッションの一覧から探す。
47
+
48
+ \`\`\`
49
+ ccmsg peers この instance が知っているセッション
50
+ ccmsg peers --all 他ホストの instance が知っている分も含める
51
+ \`\`\`
52
+
53
+ 答えは instance ごとの JSON。\`peers[]\` が今繋がっているセッション、\`last_live[]\` が
54
+ 居なくなったセッションで、各行の \`repo\` / \`ws\` / \`branch\` / \`title\` で見分けて
55
+ \`sid\` を取る。\`send_message\` が \`true\` の相手には harness 自身の機能でも届く。
56
+
57
+ ## 相手セッションの扱い
58
+
59
+ 相手は基本、自分にとってのサブエージェントだと思えばよい。対等な会議を開く場ではないので、
60
+ 冒頭の挨拶・賛辞・締めの社交辞令を省き、用件だけを 1〜3 文で送る。
61
+
62
+ やり取りの中身を人へリレーしない。人は全セッションを直接見ているので、相手の完了報告や
63
+ 根拠をこちらで要約し直しても情報は増えず、時間とコンテキストだけが減る。人に言うのは
64
+ 自セッション目線の事実 (何を頼んだ・何が返り・その結果こちらが何をしたか) だけ。
65
+
66
+ ## 見ている人へ知らせる
67
+
68
+ \`\`\`
69
+ ccmsg notify <text> 一行知らせる (保持されない、返事も来ない)
70
+ ccmsg say <text> 声に出して知らせる
71
+ \`\`\`
72
+
73
+ 手が空いた・判断を仰ぎたい・長い作業が終わった、を人に伝えるときに使う。
74
+ セッション同士のやり取りには使わない。
75
+
76
+ ## これから終わるとき
77
+
78
+ \`\`\`
79
+ ccmsg stopping --reason <理由>
80
+ \`\`\`
81
+
82
+ 以後このセッションは「一時停止」として扱われ、宛てられたメッセージは戻ってきたときに渡される。
83
+ セッション終了時には自動で伝わるので、途中で自分から言う必要はない。
84
+ `;
@@ -1,6 +1,7 @@
1
1
  import { type FSWatcher, readdirSync, readFileSync, watch } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { AgentInfo, InstanceId, Sid } from "@ccmsg/protocol";
4
+ import type { Harness } from "../harness/index.ts";
4
5
 
5
6
  /** The status the harness writes while a dialog is open and it is waiting for
6
7
  * an answer, alongside a `waitingFor` naming what it waits on.
@@ -23,6 +24,96 @@ export const CONFIRM_POLL_MS = 5_000;
23
24
 
24
25
  const STATE_FILE = /^\d+\.json$/;
25
26
 
27
+ /** Which sessions one harness says exist right now, read from its config home.
28
+ *
29
+ * Two answers rather than one, because the harnesses do not say the same
30
+ * amount. Claude Code writes a file per session carrying its pid, its working
31
+ * directory and what it is doing, which is the shape the contract's `AgentInfo`
32
+ * states and what the `agents` topic is; Codex says only that a thread has a
33
+ * live writer, which answers "is it there" and nothing else. So `rows` is what
34
+ * can be reported and `present` is what the classification reads, and a harness
35
+ * that reports nothing still has its sessions classified (§5.1). */
36
+ export interface OwnSessions {
37
+ readonly running: boolean;
38
+ /** Begins watching. Called when the first subscriber arrives and not before
39
+ * (§6.3 / §8.3: no upstream is read until somebody is listening). */
40
+ start(): void;
41
+ stop(): void;
42
+ /** The harness's own rows, as `agents` answers with them. Empty for a
43
+ * harness whose own view is not the one that contract states. */
44
+ rows(): ReadonlyMap<Sid, AgentInfo>;
45
+ /** The sessions the harness says are there at this instant. */
46
+ present(): ReadonlySet<Sid>;
47
+ }
48
+
49
+ /** The one this config home runs (§3.8). */
50
+ export function ownSessions(
51
+ harness: Harness,
52
+ configHome: string,
53
+ instance: InstanceId,
54
+ onChange: () => void,
55
+ pollMs?: number,
56
+ ): OwnSessions {
57
+ return harness === "codex"
58
+ ? new CodexThreads(join(configHome, CODEX_LOCKS), onChange, pollMs)
59
+ : new HarnessSessions(join(configHome, "sessions"), instance, onChange, pollMs);
60
+ }
61
+
62
+ /** Where the Codex thread store takes a lock while a thread has a live writer,
63
+ * and what one of those locks is called.
64
+ *
65
+ * Measured against codex-cli 0.153.4: the file appears under this directory
66
+ * while a thread is being written and is gone once the process that had it
67
+ * ends normally. The `.coordination.lock` beside them belongs to the store's
68
+ * own cleanup and names no thread, which the shape below excludes.
69
+ *
70
+ * A process killed outright leaves its lock behind (measured), so a thread
71
+ * whose session died without a word reads as present until Codex itself sweeps
72
+ * the stale lock. That is the same direction as the state file Claude Code
73
+ * leaves behind — except that a lock names no pid, so there is nothing here to
74
+ * ask whether anybody still holds it. */
75
+ const CODEX_LOCKS = "thread-writer-locks";
76
+ const THREAD_LOCK = /^([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.lock$/;
77
+
78
+ /** The Codex threads with a live writer, read from one config home.
79
+ *
80
+ * It reports no rows: `AgentInfo` is Claude Code's own list (contract), and a
81
+ * lock file carries none of what that shape states. What a Codex session is —
82
+ * where it works, what it is called — is what it said when it greeted, and
83
+ * that is held by the registry for every harness alike. */
84
+ class CodexThreads implements OwnSessions {
85
+ readonly #watch: DirectoryWatch;
86
+
87
+ constructor(dir: string, onChange: () => void, pollMs?: number) {
88
+ this.#watch = new DirectoryWatch(dir, onChange, pollMs);
89
+ }
90
+
91
+ get running(): boolean {
92
+ return this.#watch.running;
93
+ }
94
+
95
+ start(): void {
96
+ this.#watch.start();
97
+ }
98
+
99
+ stop(): void {
100
+ this.#watch.stop();
101
+ }
102
+
103
+ rows(): ReadonlyMap<Sid, AgentInfo> {
104
+ return new Map();
105
+ }
106
+
107
+ present(): ReadonlySet<Sid> {
108
+ const live = new Set<Sid>();
109
+ for (const name of this.#watch.names()) {
110
+ const sid = THREAD_LOCK.exec(name)?.[1];
111
+ if (sid !== undefined) live.add(sid);
112
+ }
113
+ return live;
114
+ }
115
+ }
116
+
26
117
  /** The sessions the harness itself reports, read from one config home.
27
118
  *
28
119
  * The directory is the whole input: it says which sessions exist and which is
@@ -34,42 +125,38 @@ const STATE_FILE = /^\d+\.json$/;
34
125
  * a question, and is done whenever one is asked. Watching it says the answer
35
126
  * may have changed, which is only worth knowing while somebody is subscribed —
36
127
  * so the watch is what the subscription drives, and no answer waits on it. */
37
- export class HarnessSessions {
38
- #watcher: FSWatcher | undefined;
39
- #timer: ReturnType<typeof setInterval> | undefined;
128
+ export class HarnessSessions implements OwnSessions {
129
+ readonly #watch: DirectoryWatch;
40
130
 
41
131
  constructor(
42
132
  private readonly dir: string,
43
133
  private readonly instance: InstanceId,
44
- private readonly onChange: () => void,
45
- private readonly pollMs: number = CONFIRM_POLL_MS,
46
- ) {}
134
+ onChange: () => void,
135
+ pollMs?: number,
136
+ ) {
137
+ this.#watch = new DirectoryWatch(dir, onChange, pollMs);
138
+ }
47
139
 
48
140
  get running(): boolean {
49
- return this.#watcher !== undefined || this.#timer !== undefined;
141
+ return this.#watch.running;
50
142
  }
51
143
 
52
- /** Begins watching. Called when the first subscriber arrives and not before
53
- * (§6.3 / §8.3: no upstream is read until somebody is listening). */
54
144
  start(): void {
55
- if (this.running) return;
56
- try {
57
- this.#watcher = watch(this.dir, this.onChange);
58
- } catch {
59
- // The directory does not exist yet — a config home whose harness has not
60
- // run. The poll below both covers the wait and picks it up when it
61
- // appears, so this is not a failure to start.
62
- this.#watcher = undefined;
63
- }
64
- this.#timer = setInterval(this.onChange, this.pollMs);
65
- this.onChange();
145
+ this.#watch.start();
66
146
  }
67
147
 
68
148
  stop(): void {
69
- this.#watcher?.close();
70
- this.#watcher = undefined;
71
- if (this.#timer !== undefined) clearInterval(this.#timer);
72
- this.#timer = undefined;
149
+ this.#watch.stop();
150
+ }
151
+
152
+ rows(): ReadonlyMap<Sid, AgentInfo> {
153
+ return this.scan();
154
+ }
155
+
156
+ /** Every session with a state file, which for this harness is the same
157
+ * reading its rows came from. */
158
+ present(): ReadonlySet<Sid> {
159
+ return new Set(this.scan().keys());
73
160
  }
74
161
 
75
162
  /** The directory as it is at this instant.
@@ -87,13 +174,7 @@ export class HarnessSessions {
87
174
  * uid's own config home (M6) — a syscall or two per session, not a wait. */
88
175
  scan(): ReadonlyMap<Sid, AgentInfo> {
89
176
  const rows = new Map<Sid, AgentInfo>();
90
- let names: string[];
91
- try {
92
- names = readdirSync(this.dir);
93
- } catch {
94
- return rows;
95
- }
96
- for (const name of names) {
177
+ for (const name of this.#watch.names()) {
97
178
  if (!STATE_FILE.test(name)) continue;
98
179
  let document: unknown;
99
180
  try {
@@ -108,6 +189,60 @@ export class HarnessSessions {
108
189
  }
109
190
  }
110
191
 
192
+ /** One directory that says what the harness's sessions are, watched while
193
+ * somebody is subscribed and read whenever an answer is wanted.
194
+ *
195
+ * The two things §6.3 separates live here. Reading the directory answers a
196
+ * question, and is done whenever one is asked. Watching it says the answer may
197
+ * have changed, which is only worth knowing while somebody is listening — so
198
+ * the watch is what the subscription drives, and no answer waits on it. */
199
+ class DirectoryWatch {
200
+ #watcher: FSWatcher | undefined;
201
+ #timer: ReturnType<typeof setInterval> | undefined;
202
+
203
+ constructor(
204
+ private readonly dir: string,
205
+ private readonly onChange: () => void,
206
+ private readonly pollMs: number = CONFIRM_POLL_MS,
207
+ ) {}
208
+
209
+ get running(): boolean {
210
+ return this.#watcher !== undefined || this.#timer !== undefined;
211
+ }
212
+
213
+ start(): void {
214
+ if (this.running) return;
215
+ try {
216
+ this.#watcher = watch(this.dir, this.onChange);
217
+ } catch {
218
+ // The directory does not exist yet — a config home whose harness has not
219
+ // run. The poll below both covers the wait and picks it up when it
220
+ // appears, so this is not a failure to start.
221
+ this.#watcher = undefined;
222
+ }
223
+ this.#timer = setInterval(this.onChange, this.pollMs);
224
+ this.onChange();
225
+ }
226
+
227
+ stop(): void {
228
+ this.#watcher?.close();
229
+ this.#watcher = undefined;
230
+ if (this.#timer !== undefined) clearInterval(this.#timer);
231
+ this.#timer = undefined;
232
+ }
233
+
234
+ /** What is in the directory now. Read in place because it is a handful of
235
+ * small entries of this uid's own config home (M6) — a syscall or two, not a
236
+ * wait. */
237
+ names(): string[] {
238
+ try {
239
+ return readdirSync(this.dir);
240
+ } catch {
241
+ return [];
242
+ }
243
+ }
244
+ }
245
+
111
246
  /** Whether the harness says this session is waiting on a dialog. */
112
247
  export function isWaiting(row: AgentInfo): boolean {
113
248
  return row.status === WAITING;