@ccmsg/cli 0.2.7 → 0.2.8
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/README.md +13 -1
- package/package.json +1 -1
- package/src/plugin/claude.ts +9 -0
- package/src/service/program.ts +140 -0
- package/src/service/service.ts +23 -2
package/README.md
CHANGED
|
@@ -7,13 +7,25 @@ together with its CLI and the plugins it hands to agents.
|
|
|
7
7
|
|
|
8
8
|
- `daemon` — holds sessions, delivery, topics and the mesh, and answers the contract's ops over UDS / WS
|
|
9
9
|
- `cli` — the entry point a session calls its instance through (`ccmsg`)
|
|
10
|
-
- `
|
|
10
|
+
- `plugin` — the plugin an agent receives (`ccmsg plugin install claude`). Claude Code is the only
|
|
11
|
+
agent that has one; a codex plugin is not implemented, and what it would take is tracked in
|
|
12
|
+
[`docs/issue/2026-09-09-codex-plugin-delivery-via-thread-queue.md`](./docs/issue/2026-09-09-codex-plugin-delivery-via-thread-queue.md)
|
|
11
13
|
|
|
12
14
|
The wire contract is [`@ccmsg/protocol`](https://github.com/kawaz/ccmsg-protocol), pinned to a
|
|
13
15
|
version here. Who may call what, and what comes back, is decided by the contract's attribute
|
|
14
16
|
table and schemas; the daemon reads them rather than carrying validation or authorization
|
|
15
17
|
branches of its own.
|
|
16
18
|
|
|
19
|
+
## What it does not do
|
|
20
|
+
|
|
21
|
+
- Serve the webui, or keep a conversation log of its own — the webui is its own static site, and transcript is the source of truth
|
|
22
|
+
- Separate privileges, or mesh with an instance of a different uid / config home — that boundary is the OS's uid and file permissions
|
|
23
|
+
- Re-derive an upstream judgment (the gateway's severity, Claude Code's permission decisions) or observe another config home
|
|
24
|
+
- Carry validation of its own, or serve v1 alongside — the contract's validator is called, and the new lineage stands as a separate instance
|
|
25
|
+
|
|
26
|
+
Authenticating a person is not on that list: the daemon answers "who came" itself, with a
|
|
27
|
+
passkey. [docs/DESIGN.md](./docs/DESIGN.md) §9 carries the reason each of these ties back to.
|
|
28
|
+
|
|
17
29
|
## Documentation
|
|
18
30
|
|
|
19
31
|
- [docs/DESIGN.md](./docs/DESIGN.md) — layers, delivery, the state model, the mesh, and how it is tested
|
package/package.json
CHANGED
package/src/plugin/claude.ts
CHANGED
|
@@ -77,6 +77,15 @@ ccmsg peers --all 他ホストの instance が知っている分も含め
|
|
|
77
77
|
居なくなったセッションで、各行の \`repo\` / \`ws\` / \`branch\` / \`title\` で見分けて
|
|
78
78
|
\`sid\` を取る。\`send_message\` が \`true\` の相手には harness 自身の機能でも届く。
|
|
79
79
|
|
|
80
|
+
## 相手セッションの扱い
|
|
81
|
+
|
|
82
|
+
相手は基本、自分にとってのサブエージェントだと思えばよい。対等な会議を開く場ではないので、
|
|
83
|
+
冒頭の挨拶・賛辞・締めの社交辞令を省き、用件だけを 1〜3 文で送る。
|
|
84
|
+
|
|
85
|
+
やり取りの中身を人へリレーしない。人は全セッションを直接見ているので、相手の完了報告や
|
|
86
|
+
根拠をこちらで要約し直しても情報は増えず、時間とコンテキストだけが減る。人に言うのは
|
|
87
|
+
自セッション目線の事実 (何を頼んだ・何が返り・その結果こちらが何をしたか) だけ。
|
|
88
|
+
|
|
80
89
|
## 見ている人へ知らせる
|
|
81
90
|
|
|
82
91
|
\`\`\`
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** Which path the init system is told to run, and what became of the one it was
|
|
2
|
+
* told.
|
|
3
|
+
*
|
|
4
|
+
* A unit file outlives the machine's software. The path this process is running
|
|
5
|
+
* as is the path of one version of it — a runtime under a version manager lives
|
|
6
|
+
* in a directory named after the version, and the next upgrade puts an
|
|
7
|
+
* identical program somewhere else and takes that directory away. Registered as
|
|
8
|
+
* it is, the supervisor works until the day it silently does not: the init
|
|
9
|
+
* system goes on asking for a path nothing is at, and the person finds out when
|
|
10
|
+
* nothing answers after a reboot.
|
|
11
|
+
*
|
|
12
|
+
* So a durable path is looked for before anything is written down, and what was
|
|
13
|
+
* written down is read back by `service status`. The judgement here is the
|
|
14
|
+
* coarse half of what `stable-which` does — whether the path names a version
|
|
15
|
+
* rather than a program — and the reading back is what makes the other half
|
|
16
|
+
* unnecessary: a pick that turns out wrong says so as a missing file rather
|
|
17
|
+
* than as silence. */
|
|
18
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
19
|
+
import { basename, delimiter, join } from "node:path";
|
|
20
|
+
import { ENTRY } from "../daemon/registry.ts";
|
|
21
|
+
import type { Env } from "../instance/paths.ts";
|
|
22
|
+
|
|
23
|
+
/** Directory names that belong to one version of something rather than to the
|
|
24
|
+
* thing itself. A path through any of them is gone at the next upgrade. */
|
|
25
|
+
const VERSIONED = ["/nix/store/", "/Cellar/", "/installs/", "/versions/", "/node_modules/"];
|
|
26
|
+
|
|
27
|
+
/** Whether this path is one a unit file may hold. */
|
|
28
|
+
export function durable(path: string): boolean {
|
|
29
|
+
return !VERSIONED.some((mark) => path.includes(mark));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How large a file may be and still be read as a wrapper script. A wrapper is
|
|
33
|
+
* a few lines; anything else is the program itself. */
|
|
34
|
+
const WRAPPER_MAX_BYTES = 64 * 1024;
|
|
35
|
+
|
|
36
|
+
function isFile(path: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
return statSync(path).isFile();
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sameFile(one: string, other: string): boolean {
|
|
45
|
+
try {
|
|
46
|
+
return realpathSync(one) === realpathSync(other);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every entry of this name on `PATH`, in the order `PATH` states them. */
|
|
53
|
+
function onPath(name: string, env: Env): string[] {
|
|
54
|
+
return (env["PATH"] ?? "")
|
|
55
|
+
.split(delimiter)
|
|
56
|
+
.filter((dir) => dir !== "")
|
|
57
|
+
.map((dir) => join(dir, name))
|
|
58
|
+
.filter((path) => isFile(path));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether running this path runs what this process is running: the same file,
|
|
62
|
+
* or a wrapper that names the script this process was started with. */
|
|
63
|
+
function leadsHere(candidate: string, self: string): boolean {
|
|
64
|
+
if (sameFile(candidate, self)) return true;
|
|
65
|
+
try {
|
|
66
|
+
if (statSync(candidate).size > WRAPPER_MAX_BYTES) return false;
|
|
67
|
+
return readFileSync(candidate, "utf8").includes(ENTRY);
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Program {
|
|
74
|
+
/** What the init system is told to run. */
|
|
75
|
+
readonly command: string[];
|
|
76
|
+
/** Whether that first path is one that survives an upgrade. False means the
|
|
77
|
+
* best that could be found still names a version, which is worth saying
|
|
78
|
+
* rather than hiding: the registration works now and is the one to redo
|
|
79
|
+
* after the next upgrade. */
|
|
80
|
+
readonly durable: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The supervisor as a path an init system can keep asking for.
|
|
84
|
+
*
|
|
85
|
+
* A `ccmsg` on `PATH` that leads back here is preferred over the runtime: it is
|
|
86
|
+
* the program by its own name, and it stays put across a runtime upgrade
|
|
87
|
+
* because it is what names the runtime rather than what the runtime is. Failing
|
|
88
|
+
* that, the runtime by name on `PATH`, which at least resolves through whatever
|
|
89
|
+
* the version manager keeps current. Failing both, this process's own path,
|
|
90
|
+
* which is where it started. */
|
|
91
|
+
export function supervisorProgram(env: Env = process.env): Program {
|
|
92
|
+
const self = process.execPath;
|
|
93
|
+
for (const candidate of onPath("ccmsg", env)) {
|
|
94
|
+
if (durable(candidate) && leadsHere(candidate, self)) {
|
|
95
|
+
return { command: [candidate, "daemon", "supervise"], durable: true };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const candidate of onPath(basename(self), env)) {
|
|
99
|
+
if (durable(candidate) && sameFile(candidate, self)) {
|
|
100
|
+
return { command: [candidate, ENTRY, "daemon", "supervise"], durable: true };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { command: [self, ENTRY, "daemon", "supervise"], durable: durable(self) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What a unit file names as its program, and whether anything is there now. */
|
|
107
|
+
export interface RegisteredProgram {
|
|
108
|
+
readonly path: string;
|
|
109
|
+
/** Whether the registered path is one that survives an upgrade. A false here
|
|
110
|
+
* is a registration that works today and will stop working quietly, which is
|
|
111
|
+
* worth seeing before it does. */
|
|
112
|
+
readonly durable: boolean;
|
|
113
|
+
/** False on a registration whose program has moved: the init system is
|
|
114
|
+
* asking for a path nothing is at, and re-registering is the answer. */
|
|
115
|
+
readonly exists: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ENTITY: Record<string, string> = { "&": "&", "<": "<", ">": ">" };
|
|
119
|
+
|
|
120
|
+
/** The program the registered unit names — read from the file rather than
|
|
121
|
+
* worked out again, because the question is what the init system was told and
|
|
122
|
+
* not what it would be told today. */
|
|
123
|
+
export function registeredProgram(
|
|
124
|
+
unitFile: string,
|
|
125
|
+
kind: "launchd" | "systemd",
|
|
126
|
+
): RegisteredProgram | null {
|
|
127
|
+
let text: string;
|
|
128
|
+
try {
|
|
129
|
+
text = readFileSync(unitFile, "utf8");
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const found =
|
|
134
|
+
kind === "launchd"
|
|
135
|
+
? /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>/.exec(text)?.[1]
|
|
136
|
+
: /^ExecStart=(\S+)/m.exec(text)?.[1];
|
|
137
|
+
if (found === undefined) return null;
|
|
138
|
+
const path = found.replace(/&|<|>/g, (entity) => ENTITY[entity] as string);
|
|
139
|
+
return { path, durable: durable(path), exists: existsSync(path) };
|
|
140
|
+
}
|
package/src/service/service.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { CommandError } from "../daemon/link.ts";
|
|
5
|
-
import { ENTRY } from "../daemon/registry.ts";
|
|
6
5
|
import { type Env, resolveStateRoot } from "../instance/paths.ts";
|
|
6
|
+
import { type RegisteredProgram, registeredProgram, supervisorProgram } from "./program.ts";
|
|
7
7
|
|
|
8
8
|
/** What the host's init system was asked, and what it said.
|
|
9
9
|
*
|
|
@@ -34,6 +34,13 @@ export interface ServiceState {
|
|
|
34
34
|
readonly registered: boolean;
|
|
35
35
|
readonly running: boolean;
|
|
36
36
|
readonly pid?: number;
|
|
37
|
+
/** The program the registered unit names, and whether anything is at that
|
|
38
|
+
* path now. `null` when nothing is registered, so there is no unit to read.
|
|
39
|
+
*
|
|
40
|
+
* Here rather than left to be worked out by a reader: a supervisor that
|
|
41
|
+
* cannot start because its program moved with a runtime upgrade looks, from
|
|
42
|
+
* every other field, exactly like one that was never started. */
|
|
43
|
+
readonly program: RegisteredProgram | null;
|
|
37
44
|
/** What the init system itself says, or `null` when it could not be asked.
|
|
38
45
|
*
|
|
39
46
|
* Beside the two fields above rather than folded into them: those are ccmsg's
|
|
@@ -101,7 +108,7 @@ export type LogSource =
|
|
|
101
108
|
* registered from a shell where these were exported and started without them
|
|
102
109
|
* would quietly manage a different set of instances. */
|
|
103
110
|
function supervisorCommand(): string[] {
|
|
104
|
-
return
|
|
111
|
+
return supervisorProgram().command;
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
const CARRIED = [
|
|
@@ -211,6 +218,13 @@ class LaunchdService implements Service {
|
|
|
211
218
|
}
|
|
212
219
|
|
|
213
220
|
async state(run: Run): Promise<ServiceState> {
|
|
221
|
+
return {
|
|
222
|
+
...(await this.#report(run)),
|
|
223
|
+
program: registeredProgram(this.unitFile, this.kind),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async #report(run: Run): Promise<Omit<ServiceState, "program">> {
|
|
214
228
|
const registered = existsSync(this.unitFile);
|
|
215
229
|
const printed = await run(["launchctl", "print", `${this.#domain}/${LAUNCHD_LABEL}`]);
|
|
216
230
|
// A non-zero exit is launchd saying it has no such service, which is not
|
|
@@ -306,6 +320,13 @@ class SystemdService implements Service {
|
|
|
306
320
|
}
|
|
307
321
|
|
|
308
322
|
async state(run: Run): Promise<ServiceState> {
|
|
323
|
+
return {
|
|
324
|
+
...(await this.#report(run)),
|
|
325
|
+
program: registeredProgram(this.unitFile, this.kind),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async #report(run: Run): Promise<Omit<ServiceState, "program">> {
|
|
309
330
|
const registered = existsSync(this.unitFile);
|
|
310
331
|
const shown = await run([
|
|
311
332
|
"systemctl",
|