@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.
|
|
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.
|
|
23
|
+
"@ccmsg/protocol": "1.18.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.0",
|
package/src/daemon/registry.ts
CHANGED
|
@@ -115,12 +115,16 @@ const STARTING_PRESETS = [
|
|
|
115
115
|
{
|
|
116
116
|
name: "howto",
|
|
117
117
|
description: "調査のノウハウだけ。何を考えて何を叩いて何を読み書きしたか",
|
|
118
|
-
opts: {
|
|
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: {
|
|
125
|
+
opts: {
|
|
126
|
+
types: ["message:user", "message:parent", "message:sub:in", "message:team:in", "thinking"],
|
|
127
|
+
},
|
|
124
128
|
},
|
|
125
129
|
{
|
|
126
130
|
name: "handoff",
|
package/src/sessions/registry.ts
CHANGED
|
@@ -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
|
|
487
|
-
*
|
|
488
|
-
*
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
281
|
-
// is
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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(
|
|
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", {
|
|
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")
|
|
482
|
+
if (said.includes("<cross-session-message")) {
|
|
441
483
|
make("message:session:in", {
|
|
442
484
|
text: said,
|
|
443
|
-
...optional("from", attribute(said, "from")
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
252
|
-
* side of the exchange goes into the key: a tool's answer belongs to
|
|
253
|
-
* and an agent's to the
|
|
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:
|
|
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")),
|