@comchatx/cx-subagents 0.1.0 → 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 +41 -28
- package/extensions/board-cards.ts +162 -0
- package/package.json +13 -19
- package/skills/cx-subagents/SKILL.md +0 -23
package/README.md
CHANGED
|
@@ -1,43 +1,56 @@
|
|
|
1
|
-
When
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
## What it costs
|
|
5
|
+
## When cx's computer stops
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
If cx's computer stops before the work is done, its card stays as it was when the work started.
|
|
10
8
|
|
|
11
|
-
##
|
|
9
|
+
## What it costs
|
|
12
10
|
|
|
13
|
-
|
|
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
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
- **
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
- **
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
- a
|
|
38
|
-
-
|
|
39
|
-
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
19
|
-
"./
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./extensions/board-cards.ts"
|
|
20
20
|
]
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
-
"
|
|
23
|
+
"extensions"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"typecheck": "tsc --noEmit",
|
|
27
27
|
"test": "vitest run"
|
|
28
28
|
},
|
|
29
29
|
"comchatx": {
|
|
30
|
-
"about": "
|
|
31
|
-
"description": "
|
|
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
|
-
|
|
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.
|
|
42
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
50
43
|
"@types/node": "26.2.0",
|
|
51
|
-
"
|
|
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 pi-subagents skill is listed, 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.
|