@rol1n/subagent 0.0.0-stage → 0.1.1
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 +34 -2
- package/bridge.ts +171 -0
- package/index.ts +590 -0
- package/mux.ts +621 -0
- package/package.json +41 -4
- package/session.ts +166 -0
- package/ui.ts +100 -0
package/session.ts
ADDED
|
@@ -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
|
+
}
|
package/ui.ts
ADDED
|
@@ -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
|
+
}
|