@rol1n/subagent 0.0.0-stage → 0.1.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.
@@ -0,0 +1,166 @@
1
+ import { openSync, readFileSync, readSync, closeSync } from "node:fs";
2
+
3
+ export interface SessionEntry {
4
+ type: string;
5
+ id: string;
6
+ parentId?: string | null;
7
+ [key: string]: unknown;
8
+ }
9
+
10
+ export interface AssistantMessage {
11
+ role: "assistant";
12
+ content?: unknown;
13
+ stopReason?: string;
14
+ errorMessage?: string;
15
+ model?: string;
16
+ usage?: Record<string, unknown>;
17
+ }
18
+
19
+ export interface SessionUsage {
20
+ model?: string;
21
+ toolCount: number;
22
+ input: number;
23
+ output: number;
24
+ cacheRead: number;
25
+ cacheWrite: number;
26
+ contextTokens: number;
27
+ cost: number;
28
+ }
29
+
30
+ export function readEntries(sessionFile: string): SessionEntry[] {
31
+ let content: string;
32
+ try {
33
+ content = readFileSync(sessionFile, "utf8");
34
+ } catch {
35
+ return [];
36
+ }
37
+ const entries: SessionEntry[] = [];
38
+ for (const line of content.split("\n")) {
39
+ if (!line.trim()) continue;
40
+ try {
41
+ entries.push(JSON.parse(line));
42
+ } catch {
43
+ // ignore malformed or mid-append lines
44
+ }
45
+ }
46
+ return entries;
47
+ }
48
+
49
+ export function getSessionId(sessionFile: string): string | null {
50
+ let fd: number | undefined;
51
+ try {
52
+ fd = openSync(sessionFile, "r");
53
+ const buf = Buffer.allocUnsafe(4096);
54
+ const bytes = readSync(fd, buf, 0, 4096, 0);
55
+ if (bytes <= 0) return null;
56
+ const nl = buf.indexOf(0x0a);
57
+ const end = nl === -1 || nl >= bytes ? bytes : nl;
58
+ const firstLine = buf.toString("utf8", 0, end).trim();
59
+ if (!firstLine) return null;
60
+ const parsed = JSON.parse(firstLine);
61
+ return parsed.type === "session" && typeof parsed.id === "string" ? parsed.id : null;
62
+ } catch {
63
+ return null;
64
+ } finally {
65
+ if (fd !== undefined) {
66
+ try {
67
+ closeSync(fd);
68
+ } catch {}
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Extracts the last assistant response text from a session JSONL file.
75
+ * Handles auto-retry error messages as fallback.
76
+ */
77
+ export function findLastAssistantMessage(sessionFile: string): string | null {
78
+ const entries = readEntries(sessionFile);
79
+ for (let i = entries.length - 1; i >= 0; i--) {
80
+ const entry = entries[i];
81
+ if (entry.type !== "message" || typeof entry.message !== "object" || entry.message === null) continue;
82
+ const msg = entry.message as AssistantMessage;
83
+ if (msg.role !== "assistant") continue;
84
+
85
+ if (Array.isArray(msg.content)) {
86
+ const texts: string[] = [];
87
+ for (const block of msg.content) {
88
+ if (typeof block === "object" && block !== null && (block as any).type === "text" && typeof (block as any).text === "string") {
89
+ texts.push((block as any).text);
90
+ }
91
+ }
92
+ const combined = texts.join("\n").trim();
93
+ if (combined) return combined;
94
+ }
95
+
96
+ if (msg.stopReason === "error" && typeof msg.errorMessage === "string" && msg.errorMessage.trim()) {
97
+ return `Worker error: ${msg.errorMessage.trim()}`;
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Summarize token usage and cost across all assistant turns.
105
+ */
106
+ export function readSessionUsage(sessionFile: string): SessionUsage | undefined {
107
+ let content: string;
108
+ try {
109
+ content = readFileSync(sessionFile, "utf8");
110
+ } catch {
111
+ return undefined;
112
+ }
113
+
114
+ const usage: SessionUsage = {
115
+ toolCount: 0,
116
+ input: 0,
117
+ output: 0,
118
+ cacheRead: 0,
119
+ cacheWrite: 0,
120
+ contextTokens: 0,
121
+ cost: 0,
122
+ };
123
+
124
+ const num = (value: unknown): number =>
125
+ typeof value === "number" && Number.isFinite(value) ? value : 0;
126
+
127
+ for (const line of content.split("\n")) {
128
+ if (!line.trim()) continue;
129
+ let entry: Record<string, unknown>;
130
+ try {
131
+ entry = JSON.parse(line);
132
+ } catch {
133
+ continue;
134
+ }
135
+
136
+ if (entry.type === "model_change" && typeof entry.modelId === "string" && entry.modelId) {
137
+ usage.model = entry.modelId;
138
+ continue;
139
+ }
140
+ if (entry.type !== "message") continue;
141
+ const message = entry.message as AssistantMessage | undefined;
142
+ if (!message || message.role !== "assistant") continue;
143
+
144
+ if (typeof message.model === "string" && message.model) usage.model = message.model;
145
+ if (Array.isArray(message.content)) {
146
+ for (const block of message.content) {
147
+ if (typeof block === "object" && block !== null && (block as any).type === "toolCall") {
148
+ usage.toolCount++;
149
+ }
150
+ }
151
+ }
152
+
153
+ const record = message.usage as Record<string, unknown> | undefined;
154
+ if (!record || typeof record !== "object") continue;
155
+ usage.input += num(record.input);
156
+ usage.output += num(record.output);
157
+ usage.cacheRead += num(record.cacheRead);
158
+ usage.cacheWrite += num(record.cacheWrite);
159
+ const total = num(record.totalTokens);
160
+ if (total > 0) usage.contextTokens = total;
161
+ const cost = record.cost as Record<string, unknown> | undefined;
162
+ if (cost && typeof cost === "object") usage.cost += num(cost.total);
163
+ }
164
+
165
+ return usage;
166
+ }
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: subagent
3
+ description: Dispatch, message, and collaborate with parallel, full-powered Pi subagent workers running in split terminal panes or silent background processes.
4
+ ---
5
+
6
+ # Subagent
7
+
8
+ Use the native `subagent` tool to delegate tasks to independent, full-powered Pi instances ("workers"). Workers run in dedicated split terminal panes (inside tmux or Zellij) or run silently as background processes (in standard terminals, VSCode, CI/CD, or headless environments). Every worker inherits all standard tools, skills, and extensions, and operates completely asynchronously.
9
+
10
+ ## Spawning Workers
11
+
12
+ Call the `subagent` tool directly. The tool returns **immediately** with an acknowledgement. You do **not** need to wait or poll:
13
+
14
+ ```json
15
+ subagent({
16
+ "task": "Investigate the authentication middleware in src/auth.ts and propose fixes.",
17
+ "name": "auth-audit"
18
+ })
19
+ ```
20
+
21
+ - `task`: Clear, comprehensive instructions for the worker.
22
+ - `name`: Short, descriptive identifier (e.g. `frontend`, `db-migration`, `tester`).
23
+ - `model`: (Optional) Model override (e.g. a faster model for simple exploration).
24
+ - `cwd`: (Optional) Working directory for the worker.
25
+
26
+ ### Parallel Workers
27
+
28
+ You can dispatch **multiple workers in parallel in a single turn**:
29
+
30
+ ```json
31
+ [
32
+ { "name": "subagent", "arguments": { "task": "Audit frontend router in src/routes/", "name": "frontend" } },
33
+ { "name": "subagent", "arguments": { "task": "Audit database models in src/db/", "name": "backend" } }
34
+ ]
35
+ ```
36
+
37
+ All workers run concurrently in separate split panes. As each worker finishes, the system automatically injects its final result into your conversation as a **steer message**, waking you up to review the findings.
38
+
39
+ ## Non-Blocking Principle
40
+
41
+ - **DO NOT** write bash scripts, `sleep` commands, `tail` logs, or polling loops to check on a worker.
42
+ - **DO NOT** fabricate or assume worker results before receiving the steer message.
43
+ - After calling `subagent`, either end your turn immediately or work on other independent tasks. The harness will wake you with the result as soon as it is ready.
44
+
45
+ ## Answering Worker Questions
46
+
47
+ Workers have an `ask_question` tool. When a worker encounters ambiguity or needs a decision:
48
+ 1. The worker pauses its work and marks itself as `waiting: ask`.
49
+ 2. You receive a steer notification: `[Subagent "<name>" question] ...`.
50
+ 3. Answer the worker by targeting its name:
51
+
52
+ ```json
53
+ subagent({
54
+ "target": "auth-audit",
55
+ "task": "Use the JWT verification method rather than session cookies."
56
+ })
57
+ ```
58
+
59
+ The worker absorbs your guidance, resumes execution, and reports back when done.
60
+
61
+ ## Stopping a Worker
62
+
63
+ If a worker is no longer needed or is off-track, terminate it:
64
+
65
+ ```json
66
+ subagent({
67
+ "target": "auth-audit",
68
+ "action": "stop"
69
+ })
70
+ ```
@@ -0,0 +1,100 @@
1
+ /**
2
+ * subagent/ui.ts — Status widget for active subagent workers above the editor.
3
+ *
4
+ * Compact, borderless, multi-worker friendly layout matching the SRP theme suite.
5
+ */
6
+
7
+ import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import { readSessionUsage, type SessionUsage } from "./session.ts";
9
+
10
+ export type WorkerState = "running" | "waiting" | "done" | "error";
11
+
12
+ export interface WorkerStatus {
13
+ name: string;
14
+ state: WorkerState;
15
+ startTime: number;
16
+ sessionFile: string;
17
+ question?: string;
18
+ error?: string;
19
+ backend?: string;
20
+ }
21
+
22
+ export function formatTokenCount(value: number): string {
23
+ if (value < 1000) return `${value}`;
24
+ if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`;
25
+ return `${(value / 1_000_000).toFixed(2)}M`;
26
+ }
27
+
28
+ export function formatElapsed(seconds: number): string {
29
+ const total = Math.max(0, Math.floor(seconds));
30
+ const m = Math.floor(total / 60);
31
+ const s = total % 60;
32
+ if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`;
33
+ return `${s}s`;
34
+ }
35
+
36
+ function usageSegments(usage: SessionUsage): string[] {
37
+ const parts: string[] = [];
38
+ if (usage.toolCount > 0) parts.push(`${usage.toolCount} calls`);
39
+ if (usage.input > 0) parts.push(`↑${formatTokenCount(usage.input)}`);
40
+ if (usage.output > 0) parts.push(`↓${formatTokenCount(usage.output)}`);
41
+ if (usage.cost > 0) parts.push(`$${usage.cost < 0.1 ? usage.cost.toFixed(4) : usage.cost.toFixed(2)}`);
42
+ return parts;
43
+ }
44
+
45
+ function stateStyle(state: WorkerState, theme: Theme): { icon: string; label: string } {
46
+ switch (state) {
47
+ case "running":
48
+ return { icon: theme.fg("accent", "●"), label: theme.fg("accent", "running") };
49
+ case "waiting":
50
+ return { icon: theme.fg("warning", "◆"), label: theme.fg("warning", "waiting: ask") };
51
+ case "done":
52
+ return { icon: theme.fg("success", "✔"), label: theme.fg("success", "done") };
53
+ case "error":
54
+ return { icon: theme.fg("error", "✖"), label: theme.fg("error", "error") };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Render multi-worker status lines for ctx.ui.setWidget.
60
+ */
61
+ export function renderWorkersWidget(workers: WorkerStatus[], theme: Theme, maxRows = 4): string[] {
62
+ if (workers.length === 0) return [];
63
+
64
+ const waitingCount = workers.filter((w) => w.state === "waiting").length;
65
+ const statusNote = waitingCount > 0 ? theme.fg("warning", ` · ${waitingCount} awaiting reply`) : "";
66
+ const header = `${theme.fg("accent", "◆")} ${theme.bold("Subagents")} ${theme.fg("dim", `· ${workers.length} active`)}${statusNote}`;
67
+
68
+ const visible = workers.slice(0, maxRows);
69
+ const overflow = workers.length - visible.length;
70
+
71
+ const rows = visible.map((worker) => {
72
+ const elapsed = formatElapsed((Date.now() - worker.startTime) / 1000);
73
+ const { icon, label } = stateStyle(worker.state, theme);
74
+
75
+ let detail = "";
76
+ if (worker.state === "waiting" && worker.question) {
77
+ const q = worker.question.length > 40 ? `${worker.question.slice(0, 37)}...` : worker.question;
78
+ detail = ` ${theme.fg("warning", `[Q: "${q}"]`)}`;
79
+ } else {
80
+ const usage = readSessionUsage(worker.sessionFile);
81
+ if (usage) {
82
+ const segments = usageSegments(usage).map((seg) => theme.fg("dim", seg));
83
+ if (segments.length > 0) detail = ` ${segments.join(theme.fg("dim", " · "))}`;
84
+ }
85
+ }
86
+
87
+ if (worker.state === "error" && worker.error) {
88
+ detail = ` ${theme.fg("error", worker.error.split("\n")[0])}`;
89
+ }
90
+
91
+ const backendTag = worker.backend === "process" ? theme.fg("dim", " [bg]") : "";
92
+ return ` ${icon} ${theme.bold(worker.name)}${backendTag} ${label} ${theme.fg("dim", elapsed)}${detail}`;
93
+ });
94
+
95
+ if (overflow > 0) {
96
+ rows.push(` ${theme.fg("muted", `+${overflow} more workers running...`)}`);
97
+ }
98
+
99
+ return [header, ...rows];
100
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@rol1n/subagent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "subagent extension from pi-kit",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/RolinShmily/pi-kit.git",
12
+ "directory": "extensions/subagent"
13
+ },
14
+ "homepage": "https://github.com/RolinShmily/pi-kit#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/RolinShmily/pi-kit/issues"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.19.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "files": [
25
+ "extensions/subagent",
26
+ "extensions/subagent/README.md"
27
+ ],
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-ai": "*",
30
+ "@earendil-works/pi-agent-core": "*",
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*",
33
+ "typebox": "*"
34
+ },
35
+ "pi": {
36
+ "extensions": [
37
+ "./extensions/subagent/index.ts"
38
+ ],
39
+ "skills": [],
40
+ "prompts": [],
41
+ "themes": []
42
+ }
43
+ }