@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/src/transcript/files.ts
CHANGED
|
@@ -1,19 +1,59 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import type { Sid } from "@ccmsg/protocol";
|
|
4
|
+
import { type Harness, HARNESS } from "../harness/index.ts";
|
|
4
5
|
import { OpError } from "../dispatch/index.ts";
|
|
5
6
|
|
|
6
|
-
/** Where the harness keeps transcripts under a config home: one directory per
|
|
7
|
-
* working directory, one `<sid>.jsonl` in it. */
|
|
8
|
-
const PROJECTS = "projects";
|
|
9
7
|
const SUFFIX = ".jsonl";
|
|
10
8
|
|
|
11
|
-
/**
|
|
9
|
+
/** Where one harness keeps transcripts under its config home, and how a file
|
|
10
|
+
* there says which session it belongs to (§3.8).
|
|
11
|
+
*
|
|
12
|
+
* Two facts, because the two harnesses file the same thing differently. Claude
|
|
13
|
+
* Code keeps one directory per working directory and names the file after the
|
|
14
|
+
* session; Codex keeps one directory per date and names the file after the
|
|
15
|
+
* thread with the moment it started in front. The `depth` is how many
|
|
16
|
+
* directories stand between the root and a file, which is what the walk needs
|
|
17
|
+
* and what the naming does not say.
|
|
18
|
+
*
|
|
19
|
+
* A Codex rollout that was reverted carries a second id after the thread's own,
|
|
20
|
+
* separated by `_`: the thread is the same and the file is a new one, so the
|
|
21
|
+
* name still answers "which session" and that is what is read out of it. */
|
|
22
|
+
interface TranscriptLayout {
|
|
23
|
+
readonly depth: number;
|
|
24
|
+
/** The session a file belongs to, or nothing when the name is not one this
|
|
25
|
+
* harness writes. */
|
|
26
|
+
readonly sidOf: (name: string) => Sid | undefined;
|
|
27
|
+
/** What that session's file is called, where the name follows from the sid.
|
|
28
|
+
* Absent where it does not, which is what makes the walk the only way in. */
|
|
29
|
+
readonly nameOf?: (sid: Sid) => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A session id as Claude Code names files by. Validated before it is joined
|
|
12
33
|
* to a path, so a sid is a name rather than a route: no separator and no dot
|
|
13
34
|
* can appear in it, which makes traversal unrepresentable rather than
|
|
14
35
|
* unlikely. */
|
|
15
36
|
const SID = /^[0-9a-fA-F-]{8,64}$/;
|
|
16
37
|
|
|
38
|
+
/** A Codex rollout, as its recorder writes the name: the moment it opened, the
|
|
39
|
+
* thread UUID, and the rollout's own id after it when the thread was reverted.
|
|
40
|
+
* The thread UUID is what a sid is here (measured against codex-cli 0.153.4). */
|
|
41
|
+
const ROLLOUT =
|
|
42
|
+
/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(?:_[0-9a-fA-F-]{36})?\.jsonl$/;
|
|
43
|
+
|
|
44
|
+
const LAYOUTS: Record<Harness, TranscriptLayout> = {
|
|
45
|
+
claude: {
|
|
46
|
+
depth: 1,
|
|
47
|
+
sidOf: (name) => {
|
|
48
|
+
if (!name.endsWith(SUFFIX)) return undefined;
|
|
49
|
+
const sid = name.slice(0, -SUFFIX.length);
|
|
50
|
+
return SID.test(sid) ? sid : undefined;
|
|
51
|
+
},
|
|
52
|
+
nameOf: (sid) => `${sid}${SUFFIX}`,
|
|
53
|
+
},
|
|
54
|
+
codex: { depth: 3, sidOf: (name) => ROLLOUT.exec(name)?.[1] },
|
|
55
|
+
};
|
|
56
|
+
|
|
17
57
|
/** The agent id and run id shapes the harness writes under a session's own
|
|
18
58
|
* directory, and the name a teammate is addressed by. Each is validated on the
|
|
19
59
|
* same footing as a sid, for the same reason: all three name a file. */
|
|
@@ -30,6 +70,8 @@ const TEAMMATE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
30
70
|
* (M6) — nothing searches for another one. */
|
|
31
71
|
export interface TranscriptFilesDeps {
|
|
32
72
|
readonly configHome: string;
|
|
73
|
+
/** Which harness's tree is under it (§3.8). */
|
|
74
|
+
readonly harness: Harness;
|
|
33
75
|
/** Where a connected session said its transcript is (§5.1). A session that
|
|
34
76
|
* never greeted has none, and the walk below answers for it. */
|
|
35
77
|
readonly announced: (sid: Sid) => string | undefined;
|
|
@@ -42,10 +84,10 @@ export class TranscriptFiles {
|
|
|
42
84
|
*
|
|
43
85
|
* Two ways to the one file, in the order of what each is good for: what the
|
|
44
86
|
* session announced is exact and costs no search, and the walk finds the
|
|
45
|
-
* file by the identity it carries in its name
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
87
|
+
* file by the identity it carries in its name for a session that never
|
|
88
|
+
* greeted or is no longer running. Both stay inside this harness's own
|
|
89
|
+
* transcript tree — the announced path because it was taken only if it was
|
|
90
|
+
* inside it, the walk because that tree is what it walks (M6). */
|
|
49
91
|
path(sid: Sid): string | undefined {
|
|
50
92
|
const announced = this.deps.announced(sid);
|
|
51
93
|
if (announced !== undefined && isFile(announced)) return announced;
|
|
@@ -117,21 +159,23 @@ export class TranscriptFiles {
|
|
|
117
159
|
* the file and what a `stat` already said about it, so neither has to stat
|
|
118
160
|
* again to decide whether to open it. */
|
|
119
161
|
all(): TranscriptFile[] {
|
|
162
|
+
const layout = LAYOUTS[this.deps.harness];
|
|
120
163
|
const found: TranscriptFile[] = [];
|
|
121
|
-
const
|
|
122
|
-
for (const project of names(projects)) {
|
|
123
|
-
const dir = join(projects, project);
|
|
164
|
+
for (const dir of directories(this.#root(), layout.depth)) {
|
|
124
165
|
for (const entry of names(dir)) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!SID.test(sid)) continue;
|
|
166
|
+
const sid = layout.sidOf(entry);
|
|
167
|
+
if (sid === undefined) continue;
|
|
128
168
|
const file = join(dir, entry);
|
|
129
169
|
const stat = statOf(file);
|
|
130
170
|
if (stat === undefined) continue;
|
|
131
171
|
found.push({
|
|
132
172
|
sid,
|
|
133
173
|
file,
|
|
134
|
-
|
|
174
|
+
// Only a layout that files by working directory has one to state,
|
|
175
|
+
// and the field is a prefilter: a tree that says nothing about where
|
|
176
|
+
// a session ran narrows nothing, and the transcript's own `cwd`
|
|
177
|
+
// decides as it already does.
|
|
178
|
+
...(this.deps.harness === "claude" ? { project: basename(dir) } : {}),
|
|
135
179
|
size: stat.size,
|
|
136
180
|
created_at: Math.round(stat.birthtimeMs || stat.ctimeMs),
|
|
137
181
|
updated_at: Math.round(stat.mtimeMs),
|
|
@@ -142,17 +186,52 @@ export class TranscriptFiles {
|
|
|
142
186
|
return found;
|
|
143
187
|
}
|
|
144
188
|
|
|
189
|
+
/** The root of this harness's transcript tree, which is the boundary every
|
|
190
|
+
* path below is inside of (M6). */
|
|
191
|
+
#root(): string {
|
|
192
|
+
return join(this.deps.configHome, HARNESS[this.deps.harness].transcripts);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The session's file, found by the identity its name carries.
|
|
196
|
+
*
|
|
197
|
+
* A layout whose name follows from the sid is joined rather than searched,
|
|
198
|
+
* which is one `stat` per directory instead of a listing; one whose name
|
|
199
|
+
* carries more than the sid is walked, because the rest of the name is
|
|
200
|
+
* exactly what this does not know. */
|
|
145
201
|
private find(sid: Sid): string | undefined {
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
202
|
+
const layout = LAYOUTS[this.deps.harness];
|
|
203
|
+
const dirs = directories(this.#root(), layout.depth);
|
|
204
|
+
const nameOf = layout.nameOf;
|
|
205
|
+
if (nameOf !== undefined) {
|
|
206
|
+
if (!SID.test(sid)) return undefined;
|
|
207
|
+
for (const dir of dirs) {
|
|
208
|
+
const file = join(dir, nameOf(sid));
|
|
209
|
+
if (isFile(file)) return file;
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
for (const dir of dirs) {
|
|
214
|
+
for (const entry of names(dir)) {
|
|
215
|
+
if (layout.sidOf(entry) === sid && isFile(join(dir, entry))) return join(dir, entry);
|
|
216
|
+
}
|
|
151
217
|
}
|
|
152
218
|
return undefined;
|
|
153
219
|
}
|
|
154
220
|
}
|
|
155
221
|
|
|
222
|
+
/** Every directory transcripts sit in, at the depth the layout files them at.
|
|
223
|
+
*
|
|
224
|
+
* Names are read rather than dates computed: what is there is what the harness
|
|
225
|
+
* wrote, and a tree with a directory nobody expected is one whose files are
|
|
226
|
+
* still found. */
|
|
227
|
+
function directories(root: string, depth: number): string[] {
|
|
228
|
+
let level = [root];
|
|
229
|
+
for (let step = 0; step < depth; step += 1) {
|
|
230
|
+
level = level.flatMap((dir) => names(dir).map((entry) => join(dir, entry)));
|
|
231
|
+
}
|
|
232
|
+
return level;
|
|
233
|
+
}
|
|
234
|
+
|
|
156
235
|
export interface AgentNames {
|
|
157
236
|
readonly agent_id?: string;
|
|
158
237
|
readonly run_id?: string;
|
|
@@ -165,8 +244,10 @@ export interface TranscriptFile {
|
|
|
165
244
|
readonly file: string;
|
|
166
245
|
/** The project directory's name, which is the working directory flattened.
|
|
167
246
|
* A lossy spelling — separators and dots all become dashes — so it prefilters
|
|
168
|
-
* a search and never decides it.
|
|
169
|
-
|
|
247
|
+
* a search and never decides it. Absent where the harness files transcripts
|
|
248
|
+
* by something other than the working directory, which leaves nothing to
|
|
249
|
+
* prefilter on. */
|
|
250
|
+
readonly project?: string;
|
|
170
251
|
readonly size: number;
|
|
171
252
|
readonly created_at: number;
|
|
172
253
|
readonly updated_at: number;
|
package/src/transcript/fold.ts
CHANGED
|
@@ -133,6 +133,11 @@ export class TranscriptFold {
|
|
|
133
133
|
return false;
|
|
134
134
|
}
|
|
135
135
|
if (!isRecord(row)) return false;
|
|
136
|
+
// A Codex rollout line settles one of these facts and none of the others,
|
|
137
|
+
// so it is folded on its own rather than run past readers of records it
|
|
138
|
+
// does not have (§3.8).
|
|
139
|
+
const rollout = rolloutRecord(row, str(row["type"]));
|
|
140
|
+
if (rollout !== undefined) return this.#foldRollout(rollout);
|
|
136
141
|
// Every value this fold derives, derived from the one parse (M5).
|
|
137
142
|
let changed = this.#foldApiError(row);
|
|
138
143
|
if (this.#foldAnswered(row)) changed = true;
|
|
@@ -223,6 +228,20 @@ export class TranscriptFold {
|
|
|
223
228
|
*
|
|
224
229
|
* A sidechain user row is a subagent being prompted by its parent, which is
|
|
225
230
|
* a session speaking to itself rather than a person speaking to it. */
|
|
231
|
+
/** What a Codex rollout says: when a person last spoke.
|
|
232
|
+
*
|
|
233
|
+
* The rest of what this fold holds — the api error, the model of the latest
|
|
234
|
+
* turn, todos, teammates, background work — are records Claude Code writes
|
|
235
|
+
* and a rollout does not, so they stay as they are for a Codex session
|
|
236
|
+
* rather than being guessed at from something that resembles them. */
|
|
237
|
+
#foldRollout(record: TranscriptRecord): boolean {
|
|
238
|
+
if (record.said_by !== "user" || record.said_at === undefined) return false;
|
|
239
|
+
if (record.text === undefined || !isHuman(record.text)) return false;
|
|
240
|
+
if ((this.#lastUserInputAt ?? 0) >= record.said_at) return false;
|
|
241
|
+
this.#lastUserInputAt = record.said_at;
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
226
245
|
#foldUserInput(row: Record<string, unknown>): boolean {
|
|
227
246
|
if (row["type"] !== "user" || row["isSidechain"] === true) return false;
|
|
228
247
|
if (row["isMeta"] === true || row["promptSource"] === "system") return false;
|
|
@@ -744,6 +763,8 @@ export function readRecord(line: string): TranscriptRecord | undefined {
|
|
|
744
763
|
}
|
|
745
764
|
if (!isRecord(row)) return undefined;
|
|
746
765
|
const type = str(row["type"]);
|
|
766
|
+
const rollout = rolloutRecord(row, type);
|
|
767
|
+
if (rollout !== undefined) return rollout;
|
|
747
768
|
const message = isRecord(row["message"]) ? row["message"] : undefined;
|
|
748
769
|
const model = message === undefined ? undefined : str(message["model"]);
|
|
749
770
|
return {
|
|
@@ -760,6 +781,52 @@ export function readRecord(line: string): TranscriptRecord | undefined {
|
|
|
760
781
|
};
|
|
761
782
|
}
|
|
762
783
|
|
|
784
|
+
/** One line of a Codex rollout, or nothing where the line is not one.
|
|
785
|
+
*
|
|
786
|
+
* A rollout says what kind of line it is in its own `type`, and the words it
|
|
787
|
+
* uses appear in no Claude Code transcript — so the two formats are told apart
|
|
788
|
+
* by the line rather than by anything the reader was told beforehand.
|
|
789
|
+
*
|
|
790
|
+
* What is read is what §5 asks a transcript for and a rollout answers: when a
|
|
791
|
+
* person last spoke, and where the session runs. The rest of the fold's facts —
|
|
792
|
+
* a session's todos, its teammates, the files it named — are Claude Code's own
|
|
793
|
+
* records, and a Codex session simply declares none of them.
|
|
794
|
+
*
|
|
795
|
+
* `developer` is not a person: Codex writes the instructions a turn runs under
|
|
796
|
+
* as messages of that role, so only `user` and `assistant` are read as somebody
|
|
797
|
+
* speaking. One of the `user` rows is not a person either — Codex opens a
|
|
798
|
+
* thread by stating the environment as `<environment_context>` — and it is the
|
|
799
|
+
* fold that turns that away, by the same rule that turns away every injected
|
|
800
|
+
* opening a Claude Code transcript carries: a row that opens with a tag is the
|
|
801
|
+
* harness talking. So the role decides who is read here, and what is read
|
|
802
|
+
* decides whether a person said it. */
|
|
803
|
+
function rolloutRecord(
|
|
804
|
+
row: Record<string, unknown>,
|
|
805
|
+
type: string | undefined,
|
|
806
|
+
): TranscriptRecord | undefined {
|
|
807
|
+
if (type === "session_meta") {
|
|
808
|
+
const payload = isRecord(row["payload"]) ? row["payload"] : undefined;
|
|
809
|
+
return {
|
|
810
|
+
sidechain: false,
|
|
811
|
+
...optional("said_at", instant(row["timestamp"])),
|
|
812
|
+
...optional("cwd", payload === undefined ? undefined : str(payload["cwd"])),
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
if (type !== "response_item") return undefined;
|
|
816
|
+
const payload = isRecord(row["payload"]) ? row["payload"] : undefined;
|
|
817
|
+
if (payload === undefined || str(payload["type"]) !== "message") return { sidechain: false };
|
|
818
|
+
const role = str(payload["role"]);
|
|
819
|
+
return {
|
|
820
|
+
sidechain: false,
|
|
821
|
+
...optional("said_at", instant(row["timestamp"])),
|
|
822
|
+
...optional<"said_by", "user" | "agent">(
|
|
823
|
+
"said_by",
|
|
824
|
+
role === "user" ? "user" : role === "assistant" ? "agent" : undefined,
|
|
825
|
+
),
|
|
826
|
+
...optional("text", blockText(payload["content"])),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
763
830
|
/** The harness's record types, in the contract's two words. Its `assistant` is
|
|
764
831
|
* the contract's `agent`; every other type is a record neither side spoke. */
|
|
765
832
|
function saidBy(type: string | undefined): "user" | "agent" | undefined {
|
|
@@ -771,6 +838,8 @@ function optional<K extends string, V>(key: K, value: V | undefined): Record<K,
|
|
|
771
838
|
return value === undefined ? {} : { [key]: value };
|
|
772
839
|
}
|
|
773
840
|
|
|
841
|
+
const TEXT_BLOCK = new Set(["text", "input_text", "output_text"]);
|
|
842
|
+
|
|
774
843
|
/** The text a message states, whoever wrote it. A plain prompt is a string; a
|
|
775
844
|
* prompt with an attachment, and every row the harness writes, is a block
|
|
776
845
|
* array whose text blocks carry the words. An array holding only tool results
|
|
@@ -781,7 +850,9 @@ function blockText(content: unknown): string | undefined {
|
|
|
781
850
|
if (!Array.isArray(content)) return undefined;
|
|
782
851
|
const parts: string[] = [];
|
|
783
852
|
for (const block of content) {
|
|
784
|
-
|
|
853
|
+
// `text` is what Claude Code writes; `input_text` and `output_text` are
|
|
854
|
+
// what a Codex rollout writes for the same thing, one per direction.
|
|
855
|
+
if (!isRecord(block) || !TEXT_BLOCK.has(block["type"] as string)) continue;
|
|
785
856
|
const text = str(block["text"]);
|
|
786
857
|
if (text !== undefined) parts.push(text);
|
|
787
858
|
}
|