@comchatx/cx-subagents 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,43 +1,56 @@
1
- When a task will take long, such as a research across several data sources or a look through many listings, cx hands it to a helper that runs in the background, tells you it has, and keeps answering you. The helper's result comes back into the conversation by itself.
1
+ When cx hands work to the background, a card for it appears under To do on the board. It holds what cx asked for. When the work is done, the same card shows its result. If it is not done after 30 minutes, the card says 超时 and shows what was done so far.
2
2
 
3
- ## When the result comes back
3
+ It needs [`pi-subagents`](https://www.npmjs.com/package/pi-subagents), which runs the work in the background, and [`@comchatx/cx-board`](https://www.npmjs.com/package/@comchatx/cx-board), the board. The Plugins page installs both with it. With pi-subagents alone, cx still works in the background, and no card is written.
4
4
 
5
- A helper runs for at most 30 minutes. When it is done, cx tells you what it found in the conversation; if it is still at work after 30 minutes, cx says it ran out. On cx's computer, at most two helpers run in the background at a time in one conversation: that is ComChatX's setting on the computer, not this package's, and a computer without that setting sets no cap.
6
-
7
- ## What it costs
5
+ ## When cx's computer stops
8
6
 
9
- A helper uses the same model as cx and is paid from the same balance. cx's computer stays on while a helper runs, and that time is billed.
7
+ If cx's computer stops before the work is done, its card stays as it was when the work started.
10
8
 
11
- ## When cx's computer stops
9
+ ## What it costs
12
10
 
13
- If cx's computer stops before a result is handed back, that result is lost and the work has to run again.
11
+ The work in the background uses the same model as cx and is paid from the same balance. cx's computer stays on while it runs, and that time is billed.
14
12
 
15
13
  ## How it is built
16
14
 
17
- - **pi-subagents, unchanged.** The helper is [`pi-subagents`](https://www.npmjs.com/package/pi-subagents) and its `subagent` tool. This package names it in its `comchatx` field's `builds`, so the Plugins page installs it with this one; it does not carry it. Its tests run against `pi-subagents` 0.83.0 on pi 1.3.0.
18
- - **One skill, `cx-subagents`.** It says when cx hands a task to the background (a long research across several sources; a look through many listings), that cx then hands the conversation back and says so, and that the result returns by itself. pi-subagents' own tool asks for an instruction before it delegates; in a ComChatX box this skill is that instruction. Its description says what it needs: pi installs nothing for a package.
19
- - **pi-subagents' config, carried by the box.** pi-subagents reads `extensions/subagent/config.json` in pi's agent directory once, when it loads, and the Plugins page installs it before this package, so a box's image carries the file instead of this package writing it:
20
-
21
- ```json
22
- {
23
- "asyncByDefault": true,
24
- "toolActivation": "eager",
25
- "maxActiveAsyncRunsPerSession": 2
26
- }
27
- ```
15
+ - **One extension, `extensions/board-cards.ts`.** pi loads it as TypeScript; there is no build. It ships no skill. When cx hands work to the background is cx's own judgement with pi-subagents' `subagent` tool.
16
+ - **What it listens to.** pi-subagents emits `subagent:async-started` and `subagent:async-complete` on the session's event bus. The start carries the run id and the `subagent` call's id, and redacts the task. The extension takes the task from pi's own `tool_call` event for that call. The end carries the result.
17
+ - **The card.** It goes in `board/<person>/todo/` under the session's directory, which in cx's computer is `/tenant`. The person is the one directory under `board/`; a board with none, or with more than one, gets no card. This is the board's shape as `cx-board` describes it. The extension imports nothing from `cx-board` and calls nothing of the board's.
18
+ - The name is `<YYYYMMDD-HHMMSS>-subagent-<run id>.md`, the start's time in UTC.
19
+ - The front matter is `kind: finding`, a `title` and an `at`. The title is the task's first line, at most 200 characters. `at` is the start.
20
+ - The words are the task as given. They are cut where the whole file would pass the 32 KiB the board reads.
21
+ - **At the end** the same file in `todo/` is rewritten, its name, title and `at` kept. The words become:
22
+ - a single run's own output;
23
+ - each run's output under its agent's name, for several;
24
+ - at the deadline, 超时, then what the run had written. pi-subagents' own note on the stop, its "Recovery summary", is left out.
28
25
 
29
- A run goes to the background unless a call says otherwise; the `subagent` tool is offered from a session's first request; and two background runs at a time in one session, where pi-subagents' own default has no cap. The 30-minute limit is pi-subagents' own default.
30
- - **What the box does for it.** A background run is a detached Node process of the model's user, so the box counts it as work and stays awake while it runs. Its state lives under `/tmp`, which a stop clears. The box drops the widget pi-subagents sets once a second while a run is in the background, since the page draws none. The notice that wakes cx when a run ends is not drawn; cx's own answer after it is.
26
+ Nothing is moved. A card the seller has already marked read stays in `done/`, and the result is a card again in `todo/` under the same name. A second start of the same run id overwrites its card. A run that ends after cx's computer stopped leaves its card as it started.
27
+ - **pi-subagents' config is carried by the box,** not this package. `asyncByDefault` sends a run to the background. `toolActivation: "eager"` offers the `subagent` tool from the first request. `maxActiveAsyncRunsPerSession: 2` caps the runs in one conversation at two. The 30 minutes is pi-subagents' own default for a background run.
31
28
 
32
29
  ## Tests
33
30
 
34
- - `test/package.test.ts`: the pinned pi lists `cx-subagents` beside `pi-subagents`' own skill, with what it needs in its description; the `comchatx` field names the same skills pi lists from this package, with their words, for the Plugins page; the package ships the skill alone and names `pi-subagents` for the page.
35
- - `test/background.test.ts`, on the pinned pi in RPC mode with a scripted model on loopback, as a box starts pi:
36
- - the `subagent` call comes back at once with the run detached;
37
- - a second message is answered while the helper is still at work;
38
- - the helper's result arrives as pi-subagents' notice, not displayed, and starts the turn in which cx relays it;
39
- - with the box's config the session holds `0/2` background runs, and without it `0/unlimited`.
31
+ The tests run against `pi-subagents` 0.85.0 on pi 1.4.0, the versions cx's computer runs.
32
+
33
+ - **`test/board.test.ts`**, on the pinned pi in RPC mode with a scripted model on loopback, as a box starts pi. Each card is read by the board's own reader, copied into `test/fixtures/board-reader.ts` with its source lines named.
34
+ - A `subagent` call writes a started card whose title is the task's first line. The result rewrites the same file.
35
+ - A run stopped at its deadline writes 超时 with what it had.
36
+ - Two runs write two files.
37
+ - The same run id started again overwrites its card.
38
+ - **`test/board-cards.test.ts`**, the extension on a bus of its own:
39
+ - the name and the front matter;
40
+ - a card found again after a reload;
41
+ - nothing moved;
42
+ - the task of a chain or a parallel run;
43
+ - no card where the board is not one person's;
44
+ - the 32 KiB cut;
45
+ - the end's words.
46
+ - **`test/package.test.ts`:**
47
+ - pi lists no skill from the package and loads its one extension;
48
+ - the package ships the extension alone;
49
+ - the package names `pi-subagents` and `@comchatx/cx-board` in `comchatx.builds`.
50
+ - **`test/background.test.ts`:**
51
+ - a run goes to the background and its result comes back into the conversation;
52
+ - the box's config caps the runs at two.
40
53
 
41
54
  ## Release
42
55
 
43
- A maintainer publishes the first version by hand, `npm publish --access public` from a checkout of the merged tag. Each later version is published by `.github/workflows/release.yml` on a `v<version>` tag, once this repository is the package's trusted publisher on npm.
56
+ `.github/workflows/release.yml` publishes each version on a `v<version>` tag, once this repository is the package's trusted publisher on npm. Until then a maintainer publishes by hand, `npm publish --access public` from a checkout of the merged tag.
@@ -0,0 +1,162 @@
1
+ import { mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+
5
+ /** pi-subagents' events for a background run, on the session's bus (`pi-subagents` `src/shared/types.js`). */
6
+ export const STARTED = "subagent:async-started";
7
+ export const COMPLETE = "subagent:async-complete";
8
+ export const TIMED_OUT_WORDS = "超时";
9
+
10
+ /** The board's shape (`parts/cx-board.md` §1): one directory per person under `board/`, cards waiting in its `todo/`. */
11
+ const BOARD = "board";
12
+ const TODO = "todo";
13
+ const DONE = "done";
14
+ const PERSON = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
15
+ /** The most of one file the board reads; a larger one is drawn as its name alone. */
16
+ export const CARD_MAX_BYTES = 32 * 1024;
17
+ const TITLE_MAX = 200;
18
+ /** The `subagent` calls whose words are kept, the latest, for a start that names its call after the call's own result. */
19
+ const ASKED_KEPT = 64;
20
+
21
+ type Card = { readonly name: string; readonly title: string; readonly at: number };
22
+ type Options = { readonly now?: () => number };
23
+
24
+ const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
25
+ const record = (value: unknown): Record<string, unknown> => (typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {});
26
+
27
+ /** What a `subagent` call asked: its task, or each step's of a chain or a parallel run, as given. */
28
+ function taskOf(input: Record<string, unknown>): string {
29
+ const own = text(input["task"]);
30
+ if (own !== undefined && own.trim() !== "") return own;
31
+ const steps = [input["tasks"], input["chain"]].flatMap((list) => (Array.isArray(list) ? list : [])).map((step) => text(record(step)["task"]) ?? "").filter((one) => one.trim() !== "");
32
+ return steps.join("\n\n");
33
+ }
34
+
35
+ const firstLine = (task: string): string => {
36
+ const line = (task.split(/\r?\n/).find((one) => one.trim() !== "") ?? "").replace(/\s+/g, " ").trim();
37
+ return line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX - 1)}…` : line;
38
+ };
39
+
40
+ const stamp = (ms: number): string => new Date(ms).toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
41
+ const idPart = (id: string): string => id.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 80);
42
+ const suffix = (id: string): string => `-subagent-${idPart(id)}.md`;
43
+
44
+ /** A card as the board reads it: front matter with its kind, title and time, then the words, cut to what the board reads whole. */
45
+ export function cardText(card: Card, words: string): string {
46
+ const head = `---\nkind: finding\ntitle: ${JSON.stringify(card.title)}\nat: ${new Date(card.at).toISOString()}\n---\n\n`;
47
+ const room = CARD_MAX_BYTES - Buffer.byteLength(head) - 1;
48
+ const body = Buffer.byteLength(words) <= room ? words : `${Buffer.from(words).subarray(0, room - 3).toString().replace(/�+$/, "")}…`;
49
+ return `${head}${body}\n`;
50
+ }
51
+
52
+ /** The one person's board under the session's directory, or none when there is not exactly one. */
53
+ function personDir(cwd: string): string | undefined {
54
+ let people: string[];
55
+ try {
56
+ people = readdirSync(join(cwd, BOARD), { withFileTypes: true }).filter((one) => one.isDirectory()).map((one) => one.name);
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ return people.length === 1 && PERSON.test(people[0]!) ? join(cwd, BOARD, people[0]!) : undefined;
61
+ }
62
+
63
+ /** A run's card already on the board, by its run id: in `todo/`, else where the seller moved it, with its title and time read back. */
64
+ function found(person: string, id: string, now: number): Card | undefined {
65
+ for (const place of [TODO, DONE]) {
66
+ let names: string[];
67
+ try { names = readdirSync(join(person, place)); } catch { continue; }
68
+ const name = names.filter((one) => one.endsWith(suffix(id)) && !one.startsWith(".")).sort().at(-1);
69
+ if (name === undefined) continue;
70
+ let lines: string[] = [];
71
+ try { lines = readFileSync(join(person, place, name), "utf8").split(/\r?\n/); } catch { /* the name alone */ }
72
+ const field = (key: string): string | undefined => lines.find((line) => line.startsWith(`${key}: `))?.slice(key.length + 2);
73
+ let title = field("title") ?? "";
74
+ try { title = JSON.parse(title) as string; } catch { /* as it stands */ }
75
+ const at = Date.parse(field("at") ?? "");
76
+ return { name, title, at: Number.isFinite(at) ? at : now };
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ /** Writes a card into `todo/` whole, so the board never reads half of one. */
82
+ function write(person: string, card: Card, words: string): void {
83
+ const dir = join(person, TODO);
84
+ mkdirSync(dir, { recursive: true });
85
+ const temp = join(dir, `.${card.name}.${process.pid}.tmp`);
86
+ writeFileSync(temp, cardText(card, words));
87
+ renameSync(temp, join(dir, card.name));
88
+ }
89
+
90
+ /** Where pi-subagents' own note on a stopped run begins, after what the helper had written: paths and checks, not the helper's words. */
91
+ const RECOVERY = "\n\nRecovery summary:";
92
+ const ownWords = (result: Record<string, unknown>): string => {
93
+ const output = text(result["output"]) ?? "";
94
+ const cut = output.indexOf(RECOVERY);
95
+ return (cut < 0 ? output : output.slice(0, cut)).trim();
96
+ };
97
+
98
+ /**
99
+ * A run's end as its card's words: a single helper's own output, or each helper's of several under its agent's name; at the deadline,
100
+ * 「超时」 and what the run had when pi-subagents stopped it.
101
+ */
102
+ export function endWords(event: Record<string, unknown>): string {
103
+ const timedOut = event["timedOut"] === true;
104
+ const results = Array.isArray(event["results"]) ? event["results"].map(record) : [];
105
+ const each = results.map((result) => ({ agent: text(result["agent"]) ?? "", words: ownWords(result) || (timedOut ? "" : (text(result["error"]) ?? "").trim()) }));
106
+ const words = each.length === 1 ? each[0]!.words : each.filter((one) => one.words !== "").map((one) => `${one.agent}:\n${one.words}`).join("\n\n");
107
+ if (timedOut) return words === "" ? TIMED_OUT_WORDS : `${TIMED_OUT_WORDS}\n\n${words}`;
108
+ return words !== "" ? words : (text(event["summary"]) ?? text(event["error"]) ?? "").trim();
109
+ }
110
+
111
+ /**
112
+ * Each background run of pi-subagents is a card on the board: at its start, the task's first line as the title, the start as its time and
113
+ * the task as its words; at its end, the same file in `todo/` with the result as its words. Nothing is moved; a card the seller already
114
+ * moved to `done/` stays there, and the result is a card again in `todo/` under the same name.
115
+ */
116
+ export function boardCards(pi: ExtensionAPI, options: Options = {}): void {
117
+ const now = options.now ?? Date.now;
118
+ const asked = new Map<string, string>();
119
+ const running = new Set<string>();
120
+ const cards = new Map<string, Card>();
121
+ let cwd = process.cwd();
122
+
123
+ pi.on("session_start", (_event, ctx) => { cwd = ctx.cwd; });
124
+ pi.on("tool_call", (event, ctx) => {
125
+ cwd = ctx.cwd;
126
+ if (event.toolName !== "subagent") return;
127
+ asked.set(event.toolCallId, taskOf(record(event.input)));
128
+ running.add(event.toolCallId);
129
+ for (const old of asked.keys()) if (asked.size > ASKED_KEPT) asked.delete(old);
130
+ });
131
+ pi.on("tool_result", (event) => { running.delete(event.toolCallId); });
132
+
133
+ pi.events.on(STARTED, (data) => {
134
+ const event = record(data);
135
+ const id = text(event["id"]);
136
+ const person = personDir(cwd);
137
+ if (id === undefined || person === undefined) return;
138
+ const toolCallId = text(event["toolCallId"]);
139
+ const own = toolCallId === undefined ? undefined : asked.get(toolCallId);
140
+ const task = own ?? (running.size === 1 ? asked.get([...running][0]!) : undefined) ?? "";
141
+ const at = now();
142
+ const before = cards.get(id) ?? found(person, id, at);
143
+ const card = { name: before?.name ?? `${stamp(at)}${suffix(id)}`, title: firstLine(task) || (text(event["agent"]) ?? "subagent"), at };
144
+ cards.set(id, card);
145
+ write(person, card, task);
146
+ });
147
+
148
+ pi.events.on(COMPLETE, (data) => {
149
+ const event = record(data);
150
+ const id = text(event["id"]) ?? text(event["runId"]);
151
+ const person = personDir(cwd);
152
+ if (id === undefined || person === undefined) return;
153
+ const at = now();
154
+ const card = cards.get(id) ?? found(person, id, at) ?? { name: `${stamp(at)}${suffix(id)}`, title: "", at };
155
+ cards.delete(id);
156
+ write(person, { ...card, title: card.title || (text(event["agent"]) ?? "subagent") }, endWords(event));
157
+ });
158
+ }
159
+
160
+ export default function (pi: ExtensionAPI): void {
161
+ boardCards(pi);
162
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@comchatx/cx-subagents",
3
- "version": "0.1.1",
4
- "description": "Lets cx hand a long task to a helper in the background and keep answering you; the result comes back into the conversation by itself.",
3
+ "version": "0.2.0",
4
+ "description": "Puts what cx does in the background on the board.",
5
5
  "license": "MIT",
6
6
  "author": "ComChatX",
7
7
  "homepage": "https://github.com/comchatx/cx-subagents#readme",
@@ -15,40 +15,34 @@
15
15
  ],
16
16
  "type": "module",
17
17
  "pi": {
18
- "skills": [
19
- "./skills"
18
+ "extensions": [
19
+ "./extensions/board-cards.ts"
20
20
  ]
21
21
  },
22
22
  "files": [
23
- "skills"
23
+ "extensions"
24
24
  ],
25
25
  "scripts": {
26
26
  "typecheck": "tsc --noEmit",
27
27
  "test": "vitest run"
28
28
  },
29
29
  "comchatx": {
30
- "about": "让 cx 把耗时的任务交给后台助手,自己继续回答你。",
31
- "description": "任务要花很长时间时,比如跨几个数据源做调研、逐个查看大量 listing,cx 会把它交给一个在后台运行的助手,告诉你它这样做了,然后继续回答你。助手的结果会自己回到对话里。\n\n## 结果什么时候回来\n\n助手最多运行 30 分钟。做完时,cx 会在对话里告诉你结果;到 30 分钟还没做完,cx 会说它超时了。在 cx 的电脑上,同一个对话里最多同时有两个助手在后台运行:这是 ComChatX 在电脑上的设置,不是这个插件的;没有这项设置的电脑不限个数。\n\n## 费用\n\n助手和 cx 用同一个模型,由同一份余额付费。助手运行时,cx 的电脑保持开机,开机时长也要付费。\n\n## 电脑关机时\n\n如果 cx 的电脑在结果交回之前关机,这个结果就丢了,需要重新运行。",
30
+ "about": "把 cx 在后台做的事放到看板上。",
31
+ "description": "cx 在后台做一件事时,看板的待处理里会出现一张卡片,写着 cx 交代的内容。做完后,卡片换成结果;30 分钟还没做完,卡片显示「超时」和已做到的部分。\n\n## 费用\n\n后台的工作和 cx 用同一个模型,由同一份余额付费。后台有工作时,cx 的电脑保持开机,开机时长也要付费。",
32
32
  "mark": {
33
33
  "line": "rocket"
34
34
  },
35
35
  "section": "extensions",
36
36
  "builds": [
37
- "pi-subagents"
38
- ],
39
- "skills": {
40
- "cx-subagents": {
41
- "about": {
42
- "en": "Lets cx hand a long task to a helper in the background and keep answering; the result comes back into the conversation.",
43
- "zh": "让 cx 把耗时的任务交给后台助手并继续回答;结果会回到对话里。"
44
- }
45
- }
46
- }
37
+ "pi-subagents",
38
+ "@comchatx/cx-board"
39
+ ]
47
40
  },
48
41
  "devDependencies": {
49
- "@earendil-works/pi-coding-agent": "1.3.0",
42
+ "@earendil-works/pi-coding-agent": "1.4.0",
50
43
  "@types/node": "26.2.0",
51
- "pi-subagents": "0.83.0",
44
+ "@vitest/coverage-v8": "4.1.11",
45
+ "pi-subagents": "0.85.0",
52
46
  "typescript": "7.0.2",
53
47
  "vitest": "4.1.11"
54
48
  }
@@ -1,23 +0,0 @@
1
- ---
2
- name: cx-subagents
3
- description: "When a task will take long - a research across several data sources, a look through many listings - cx hands it to a helper in the background with pi-subagents' `subagent` tool, tells the seller it has, and keeps answering; the helper's result comes back into the conversation on its own. Needs pi-subagents; if no `subagent` tool is offered, it is not installed, and the seller installs it in Plugins."
4
- ---
5
-
6
- ## When a task goes to the background
7
-
8
- - A research across several data sources, or a look through many listings: work that would hold the conversation for minutes.
9
- - The seller asked for the work. In this box that is the instruction to hand a long piece of it to a helper; ask nothing more first.
10
- - Not for a quick answer, a single read, or a step whose result the next answer needs at once: do those in the conversation.
11
-
12
- ## How
13
-
14
- - Call `subagent` with one agent and the whole task in your own words: what to look at, what to bring back. Leave `async` out: a run goes to the background here.
15
- - Then hand the conversation back: tell the seller in one sentence that the work runs in the background and that its result will come here, and answer whatever they ask next.
16
- - On cx's computer at most two helpers run at a time in one conversation, by ComChatX's setting on the computer, and a third is refused until one ends; a computer without that setting sets no cap.
17
-
18
- ## When it comes back
19
-
20
- - The result arrives by itself, as a message that starts a turn of yours. Tell the seller what the helper found, in your words, as the answer to what they asked for.
21
- - A helper stops at 30 minutes, and its message says so: tell the seller it ran out, and offer to split the work.
22
- - If cx's computer stops before a result is handed back, that result is lost. If the seller asks after a stop, say so and offer to run it again.
23
- - A helper uses the same model as you, paid from the same balance, and cx's computer stays on while it runs.