@ccmsg/cli 0.6.0 → 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.0",
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.15.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",
@@ -227,6 +227,18 @@ export class Sessions implements UpstreamResource {
227
227
  ? undefined
228
228
  : new TerminalCache(deps.terminals, () => this.changed());
229
229
  this.#live = this.#liveNow(Date.now(), this.#own());
230
+ this.#reclaim(this.#live);
231
+ }
232
+
233
+ /** Drop the `last_live` entry of every session that is live, which is what
234
+ * keeps one session off both lists.
235
+ *
236
+ * Registering is one way a session comes back and is handled where it
237
+ * happens; the harness naming it again is the other, and it is the only one
238
+ * on a restart — nothing greets a daemon that was not there when the session
239
+ * started. */
240
+ #reclaim(live: ReadonlyMap<Sid, StoredEntry>): void {
241
+ for (const sid of live.keys()) this.#lastLive.remove(sid);
230
242
  }
231
243
 
232
244
  /** `hello`, which is where a session becomes something this instance can
@@ -483,9 +495,15 @@ export class Sessions implements UpstreamResource {
483
495
  return this.#harness.running;
484
496
  }
485
497
 
486
- /** The `peers` payload: what is connected now, and what was connected when
487
- * this instance last saw it. Both travel together because registering is
488
- * exactly what moves a session from the second list to the first.
498
+ /** The `peers` payload: what is live now, and what was live when this
499
+ * instance last saw it. Both travel together because coming back is exactly
500
+ * what moves a session from the second list to the first.
501
+ *
502
+ * Live is not the same as connected (§5.2). A session the harness names is
503
+ * live whether or not it ever greeted us, and it has to be on this list for
504
+ * the same reason it is classified at all: a restart forgets every greeting,
505
+ * and a list that showed only what had greeted this daemon would show a host
506
+ * full of running sessions as empty.
489
507
  *
490
508
  * Every row states its `state` and its `pinned`. The contract lets an
491
509
  * instance leave them out, and a client then shows a session it cannot group
@@ -504,7 +522,12 @@ export class Sessions implements UpstreamResource {
504
522
  ): { peers: PeerInfo[]; last_live: LastLiveSession[]; instances?: InstanceInfo[] } {
505
523
  const instances = this.deps.mesh?.instances();
506
524
  return {
507
- peers: [...this.#connected.values()].map((session) => this.#peer(session, now, own)),
525
+ peers: [
526
+ ...[...this.#connected.values()].map((session) => this.#peer(session, now, own)),
527
+ ...[...own.present]
528
+ .filter((sid) => !this.#connected.has(sid))
529
+ .map((sid) => this.#unconnected(sid, now, own)),
530
+ ],
508
531
  last_live: this.#lastLive.entries(now).map((entry) => ({
509
532
  ...entry,
510
533
  state: this.classify(entry.sid, now, own) ?? "disappeared",
@@ -597,6 +620,7 @@ export class Sessions implements UpstreamResource {
597
620
  // comes back says them again.
598
621
  this.#stated.delete(sid);
599
622
  }
623
+ this.#reclaim(live);
600
624
  this.#live = live;
601
625
  this.deps.publish("peers", this.peers(now, own));
602
626
  this.deps.publish("agents", this.agents(own));
@@ -661,6 +685,36 @@ export class Sessions implements UpstreamResource {
661
685
  };
662
686
  }
663
687
 
688
+ /** A session the harness names that holds no connection here (§5.1).
689
+ *
690
+ * It is on the same list as the connected ones because it is live in the same
691
+ * sense: the classification is what separates them, and a client groups on
692
+ * that field alone (§5.2). What it cannot carry is everything a greeting
693
+ * states — the session never said where it works, so the working directory
694
+ * comes from the harness's own row and the display names it does not know are
695
+ * simply absent.
696
+ *
697
+ * The connection fields go with the connection: `connected_at`,
698
+ * `last_activity_at` and the client's build and generation are things about a
699
+ * client of this session, and there is none. */
700
+ #unconnected(sid: Sid, now: Timestamp, own: Own): PeerInfo {
701
+ const row = own.rows.get(sid);
702
+ const userInput = this.deps.transcript?.facts(sid).last_user_input_at;
703
+ const gatewayActiveAt = this.#gatewayActiveAt(sid, true);
704
+ return {
705
+ sid,
706
+ instance: this.deps.self,
707
+ // The harness knows a title for a session that stated none itself, and
708
+ // what the session said about itself overrides it.
709
+ ...(row?.name === undefined ? {} : { title: row.name }),
710
+ ...this.#where(sid, own),
711
+ state: this.classify(sid, now, own) ?? "live",
712
+ pinned: this.#pinned(sid),
713
+ ...(userInput === undefined ? {} : { last_user_input_at: userInput }),
714
+ ...(gatewayActiveAt === undefined ? {} : { gateway_active_at: gatewayActiveAt }),
715
+ };
716
+ }
717
+
664
718
  /** When the gateway last saw inference for a session, for a session this
665
719
  * instance knows (§5.1).
666
720
  *
@@ -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")),