@ccmsg/cli 0.6.1 → 0.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -20,7 +20,7 @@
20
20
  "test": "bun test"
21
21
  },
22
22
  "dependencies": {
23
- "@ccmsg/protocol": "1.16.0"
23
+ "@ccmsg/protocol": "1.18.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "^1.3.0",
@@ -115,12 +115,16 @@ const STARTING_PRESETS = [
115
115
  {
116
116
  name: "howto",
117
117
  description: "調査のノウハウだけ。何を考えて何を叩いて何を読み書きしたか",
118
- opts: { types: ["thinking", "message:user", "message:sub", "tool:Bash", "@file"] },
118
+ opts: {
119
+ types: ["thinking", "message:user", "message:parent", "message:sub", "tool:Bash", "@file"],
120
+ },
119
121
  },
120
122
  {
121
123
  name: "journal",
122
124
  description: "日記用。人との往復と worker の答え、思考は要点だけ",
123
- opts: { types: ["message:user", "message:sub:in", "thinking"] },
125
+ opts: {
126
+ types: ["message:user", "message:parent", "message:sub:in", "message:team:in", "thinking"],
127
+ },
124
128
  },
125
129
  {
126
130
  name: "handoff",
@@ -117,6 +117,17 @@ export class Classification {
117
117
  #turn = 0;
118
118
  /** The last slash command invoked, which is what its output belongs to. */
119
119
  #slash: string | undefined;
120
+ /** Whose file this is, which decides who is at the other end of a plain
121
+ * line. A session's own transcript has a person there; a file written for an
122
+ * agent has whoever started it, and calling that `user` would have a reader
123
+ * take a machine for a person.
124
+ *
125
+ * The file says so itself — every record of an agent's transcript is marked
126
+ * as one — so nothing has to be passed in beside it. It is remembered once
127
+ * seen rather than read per record: a file is one subject's throughout, and a
128
+ * record that omitted the mark would otherwise change who the subject is
129
+ * mid-read. */
130
+ #subject: "session" | "agent" = "session";
120
131
 
121
132
  /** The records of one chunk as the items they were read as, oldest first.
122
133
  *
@@ -147,6 +158,7 @@ export class Classification {
147
158
  read(record: Row, source: { offset: number; bytes: number }): void {
148
159
  const type = str(record["type"]);
149
160
  if (type === undefined || NOT_ITEMS.has(type)) return;
161
+ if (record["isSidechain"] === true) this.#subject = "agent";
150
162
  // A record the harness wrote without an id of its own still happened, and
151
163
  // an item is pointed at by the record it came from — so where the record
152
164
  // stands in the file stands in for the id it lacks. The `@` says which of
@@ -239,7 +251,12 @@ export class Classification {
239
251
  }
240
252
  if (kind === "text") {
241
253
  const said = str(fields["text"])?.trim();
242
- if (said !== undefined && said !== "") make("message:user:out", { text: said });
254
+ // An agent's words are addressed to whoever started it the last of
255
+ // them is the answer it was started for, and the ones before are what
256
+ // it hands back mid-flight. No call carries them, which is why
257
+ // `parent:out` is prose as well as a call.
258
+ const kind = this.#subject === "agent" ? "message:parent:out" : "message:user:out";
259
+ if (said !== undefined && said !== "") make(kind, { text: said });
243
260
  continue;
244
261
  }
245
262
  if (kind === "tool_use") this.#call(fields, make);
@@ -267,29 +284,51 @@ export class Classification {
267
284
  });
268
285
  let message: Draft | undefined;
269
286
  if (SPAWNS.has(name)) {
270
- message = make("message:sub:out", {
271
- role: "use",
272
- tool_use_id: id,
273
- prompt: str(input["prompt"]) ?? "",
274
- ...optional("subagent_type", str(input["subagent_type"])),
275
- ...optional("name", str(input["name"])),
276
- ...optional("description", str(input["description"])),
277
- });
287
+ // A name is what makes an agent a teammate: it stands under that name,
288
+ // can be written to again, and answers whenever it writes back. An agent
289
+ // started without one is an errand — it runs once, answers the call that
290
+ // started it, and is done.
291
+ const named = str(input["name"]) ?? str(input["team_name"]);
292
+ message =
293
+ named === undefined
294
+ ? make("message:sub:out", {
295
+ role: "use",
296
+ tool_use_id: id,
297
+ prompt: str(input["prompt"]) ?? "",
298
+ ...optional("subagent_type", str(input["subagent_type"])),
299
+ ...optional("description", str(input["description"])),
300
+ })
301
+ : make("message:team:out", {
302
+ role: "use",
303
+ tool_use_id: id,
304
+ text: str(input["prompt"]) ?? "",
305
+ harness_name: named,
306
+ ...optional("subagent_type", str(input["subagent_type"])),
307
+ ...optional("description", str(input["description"])),
308
+ });
278
309
  } else if (name === "SendMessage") {
279
310
  const to = str(input["to"]) ?? "";
280
- // A sid is the harness's own uuid; anything else is a name, and a name
281
- // is how an agent below this session is addressed.
282
- make(addressed(to) ? "message:session:out" : "message:sub:out", {
283
- ...(addressed(to) ? {} : { role: "use", tool_use_id: id }),
284
- ...(addressed(to)
285
- ? { text: text(input["message"]) ?? "", to }
286
- : // Writing to an agent is one direction of a correspondence, not a
287
- // call that returns: what the agent says back arrives as its own
288
- // message whenever it chooses to send one, under nothing that
289
- // names this. So the brief says it is waiting for nothing, and a
290
- // reader is not left watching for an answer that has no way in.
291
- { prompt: text(input["message"]) ?? "", name: to, one_way: true }),
292
- });
311
+ // A sid is the harness's own uuid, so a message addressed by one goes to
312
+ // another session. Everything else is a name: the one above answers to
313
+ // the names a harness gives a lead, and any other name is somebody
314
+ // standing alongside.
315
+ if (addressed(to)) {
316
+ make("message:session:out", { text: text(input["message"]) ?? "", to });
317
+ } else {
318
+ // Writing to an agent is one direction of a correspondence, not a call
319
+ // that returns: what it says back arrives as its own message whenever
320
+ // it chooses to send one, under nothing that names this. So the
321
+ // message says it is waiting for nothing, and a reader is not left
322
+ // watching for an answer that has no way in.
323
+ make(LEADS.has(to) ? "message:parent:out" : "message:team:out", {
324
+ role: "use",
325
+ tool_use_id: id,
326
+ text: text(input["message"]) ?? "",
327
+ harness_name: to,
328
+ ...optional("summary", str(input["summary"])),
329
+ one_way: true,
330
+ });
331
+ }
293
332
  } else if (name === "Bash" && isCcmsgSend(str(input["command"]))) {
294
333
  make("message:session:out", { text: str(input["command"]) ?? "" });
295
334
  }
@@ -375,7 +414,7 @@ export class Classification {
375
414
  // decides is whether an answer came back, not which tool was called.
376
415
  const said = answered(result["content"]);
377
416
  if (said === undefined) return;
378
- const reply = make("message:sub:in", {
417
+ const reply = make(answers(call.message), {
379
418
  role: "result",
380
419
  parent_item: call.message.id,
381
420
  parent_tool_use_id: id,
@@ -434,17 +473,32 @@ export class Classification {
434
473
  // stands, being told what to do is not the same as being written to.
435
474
  if (record["parentUuid"] === null) {
436
475
  this.#turn += 1;
437
- make("message:user:in", { text: said });
476
+ make(this.#subject === "agent" ? "message:parent:in" : "message:user:in", {
477
+ text: said,
478
+ ...(this.#subject === "agent" ? envelope(said) : {}),
479
+ });
438
480
  return;
439
481
  }
440
- if (said.includes("<cross-session-message") || said.includes("<teammate-message")) {
482
+ if (said.includes("<cross-session-message")) {
441
483
  make("message:session:in", {
442
484
  text: said,
443
- ...optional("from", attribute(said, "from") ?? attribute(said, "teammate_id")),
485
+ ...optional("from", attribute(said, "from")),
444
486
  ...optional("msg_id", attribute(said, "mid")),
445
487
  });
446
488
  return;
447
489
  }
490
+ if (said.includes("<teammate-message")) {
491
+ // Who wrote decides which it is. A lead is the one above, whatever the
492
+ // subject's own place in the team; anyone else writing under their own
493
+ // name is somebody standing alongside, and what they send is a message
494
+ // of its own rather than the answer to anything.
495
+ const from = attribute(said, "teammate_id");
496
+ make(LEADS.has(from ?? "") ? "message:parent:in" : "message:team:in", {
497
+ text: said,
498
+ ...envelope(said),
499
+ });
500
+ return;
501
+ }
448
502
  if (record["isMeta"] === true) {
449
503
  make("system:unknown", { record });
450
504
  return;
@@ -471,7 +525,7 @@ export class Classification {
471
525
  const call = this.#calls.get(key);
472
526
  if (answer !== undefined && call !== undefined) {
473
527
  const asked = call.message ?? call.tool;
474
- const item = make("message:sub:in", {
528
+ const item = make(answers(asked), {
475
529
  role: "result",
476
530
  parent_item: asked.id,
477
531
  parent_tool_use_id: key,
@@ -503,6 +557,30 @@ function addressed(to: string): boolean {
503
557
  return SID.test(to);
504
558
  }
505
559
 
560
+ /** The names a harness gives the one above. They are the harness's own words
561
+ * rather than a relation anyone chose, which is why they are matched here and
562
+ * not carried into the type: what the type says is that this is the parent,
563
+ * and the spelling stays on the item as the name it was addressed by. */
564
+ const LEADS = new Set(["main", "team-lead"]);
565
+
566
+ /** Who a message came from and under which id, as the envelope the harness
567
+ * wraps one in says it. The name is the harness's own spelling and not a sid,
568
+ * which is why it travels as `harness_name`: what the type already said is
569
+ * which party this was, and the name is only what that party was called. */
570
+ function envelope(said: string): Record<string, unknown> {
571
+ return {
572
+ ...optional("harness_name", attribute(said, "teammate_id")),
573
+ ...optional("msg_id", attribute(said, "mid")),
574
+ };
575
+ }
576
+
577
+ /** The type an agent's answer arrives under, which is the other half of
578
+ * whatever asked for it: a teammate's run ending answers the call that started
579
+ * it, an errand's answer is the errand's result. */
580
+ function answers(asked: Draft): string {
581
+ return asked.type === "message:team:out" ? "message:team:in" : "message:sub:in";
582
+ }
583
+
506
584
  /** Whether a shell command is this session speaking to another one. */
507
585
  function isCcmsgSend(command: string | undefined): boolean {
508
586
  if (command === undefined) return false;
@@ -83,7 +83,7 @@ function heading(file: SessionDumpFile, view: DumpView): string[] {
83
83
  function draw(item: Item, child: Item | undefined, view: DumpView, parent?: string): string[] {
84
84
  const own = fragment(item);
85
85
  const answer = child === undefined ? undefined : fragment(child);
86
- const nested = child !== undefined && child.type.startsWith("message:sub");
86
+ const nested = child !== undefined && spoken(child.type);
87
87
  const link = isResult(item)
88
88
  ? arrow("←", parent)
89
89
  : (arrow("→", fields(item)["result_item"]) ?? waiting(item));
@@ -240,7 +240,7 @@ function pair(items: readonly Item[]): {
240
240
  // A pair the reader would have to scroll between is left where each half
241
241
  // happened, unless it is an agent's: what an agent was asked and what it
242
242
  // answered are one exchange whatever fell between them.
243
- if (!item.type.startsWith("message:sub") && call !== at - 1) continue;
243
+ if (!spoken(item.type) && call !== at - 1) continue;
244
244
  child.set(call, at);
245
245
  folded.add(at);
246
246
  }
@@ -248,10 +248,19 @@ function pair(items: readonly Item[]): {
248
248
  }
249
249
 
250
250
  /** The key an exchange is joined on. One call the harness gave a key to is two
251
- * items where it started an agent — the call and the brief beside it — so the
252
- * side of the exchange goes into the key: a tool's answer belongs to the call
253
- * and an agent's to the brief, and the harness's key alone would not say
254
- * which. */
251
+ * items where it also addressed somebody — the call and the message beside it
252
+ * — so the side of the exchange goes into the key: a tool's answer belongs to
253
+ * the call and an agent's to the message, and the harness's key alone would
254
+ * not say which. */
255
255
  function joined(item: Item, key: string): string {
256
- return `${item.type.startsWith("message:sub") ? "sub" : "tool"}\n${key}`;
256
+ return `${item.type.startsWith("message:") ? "message" : "tool"}\n${key}`;
257
+ }
258
+
259
+ /** Whether an item is the conversation half of starting an agent, as opposed
260
+ * to the call's own half. What was asked and what came back is one exchange
261
+ * however many turns fell between them, so these are drawn together wherever
262
+ * they ended up — a conversation split across the page is one nobody can
263
+ * follow. */
264
+ function spoken(type: string): boolean {
265
+ return type.startsWith("message:sub") || type.startsWith("message:team");
257
266
  }
Binary file
@@ -88,6 +88,44 @@ const ITEMS: Record<string, Draw> = {
88
88
  body: lines(str(item, "text")),
89
89
  }),
90
90
 
91
+ // The one above and the ones alongside. A name is on the heading wherever
92
+ // the record gave one — an answer handed back as prose names nobody, and a
93
+ // heading that invented a name for it would say more than the file does.
94
+ "message:parent:in": (item) => ({
95
+ head: words(field(item, "harness_name", "from="), mid(item)),
96
+ body: lines(str(item, "text")),
97
+ }),
98
+
99
+ "message:parent:out": (item) => ({
100
+ head: words(field(item, "harness_name", "to="), str(item, "summary")),
101
+ body: lines(str(item, "text")),
102
+ }),
103
+
104
+ "message:team:out": (item) => ({
105
+ head: words(
106
+ field(item, "harness_name", "to="),
107
+ field(item, "agent_id", "agent="),
108
+ field(item, "subagent_type", "type="),
109
+ str(item, "description"),
110
+ str(item, "summary"),
111
+ ),
112
+ body: lines(str(item, "text")),
113
+ }),
114
+
115
+ // Both halves of a teammate's correspondence arrive under one type: a letter
116
+ // it wrote, which names who wrote it, and its run ending, which names how it
117
+ // ended. Each heading says whichever of those the item carried.
118
+ "message:team:in": (item) => ({
119
+ head: words(
120
+ field(item, "harness_name", "from="),
121
+ mid(item),
122
+ field(item, "agent_id", "agent="),
123
+ field(item, "status", "status="),
124
+ elapsed(num(item, "duration_ms")),
125
+ ),
126
+ body: lines(str(item, "text")),
127
+ }),
128
+
91
129
  "message:session:out": (item) => ({
92
130
  head: words(field(item, "to", "to="), field(item, "reply_to", "reply_to="), mid(item)),
93
131
  body: lines(str(item, "text")),