@narumitw/pi-subagents 0.42.0 → 0.43.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/src/params.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { type Static, Type } from "typebox";
3
3
  import { THINKING_LEVELS } from "./agents.js";
4
+ import { MAX_SUBAGENT_TIMEOUT_MS } from "./limits.js";
4
5
 
5
6
  const TimeoutMs = Type.Number({
6
7
  description:
7
8
  "Hard timeout in milliseconds for each subagent subprocess. Defaults to PI_SUBAGENT_TIMEOUT_MS or 600000.",
8
9
  minimum: 1,
10
+ maximum: MAX_SUBAGENT_TIMEOUT_MS,
9
11
  });
10
12
 
11
13
  const ThinkingLevelSchema = StringEnum(THINKING_LEVELS, {
@@ -167,6 +167,8 @@ function isStoredState(value: unknown): value is StoredState {
167
167
  Number.isFinite(record.updatedAt) &&
168
168
  (record.parentId === undefined || typeof record.parentId === "string") &&
169
169
  (record.thinkingLevel === undefined || isThinkingLevel(record.thinkingLevel)) &&
170
+ (record.workspaceMode === undefined || record.workspaceMode === "worktree") &&
171
+ (record.target === undefined || isTargetPolicyAudit(record.target)) &&
170
172
  (record.children === undefined ||
171
173
  (Array.isArray(record.children) &&
172
174
  record.children.every((id) => typeof id === "string"))) &&
@@ -178,6 +180,33 @@ function isStoredState(value: unknown): value is StoredState {
178
180
  });
179
181
  }
180
182
 
183
+ function isTargetPolicyAudit(value: unknown): boolean {
184
+ if (!value || typeof value !== "object") return false;
185
+ const target = value as Record<string, unknown>;
186
+ if (
187
+ typeof target.cwd !== "string" ||
188
+ (target.boundary !== "current-workspace" && target.boundary !== "external") ||
189
+ !target.trust ||
190
+ typeof target.trust !== "object"
191
+ ) {
192
+ return false;
193
+ }
194
+ const trust = target.trust as Record<string, unknown>;
195
+ return (
196
+ [
197
+ "session-trusted",
198
+ "session-untrusted",
199
+ "saved-trusted",
200
+ "saved-denied",
201
+ "unsaved",
202
+ "trust-error",
203
+ ].includes(String(trust.kind)) &&
204
+ typeof trust.projectTrusted === "boolean" &&
205
+ (trust.sourcePath === undefined || typeof trust.sourcePath === "string") &&
206
+ (trust.warning === undefined || typeof trust.warning === "string")
207
+ );
208
+ }
209
+
181
210
  function isAgentTurn(value: unknown): boolean {
182
211
  if (!value || typeof value !== "object") return false;
183
212
  const turn = value as Record<string, unknown>;
package/src/registry.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { SubagentThinkingLevel } from "./agents.js";
3
+ import type { TargetPolicyAudit } from "./cwd-policy.js";
3
4
  import { DEFAULT_MAX_CONTEXT_BYTES, DEFAULT_MAX_OUTPUT_BYTES, truncateUtf8 } from "./limits.js";
4
5
  import { type AgentTurnRunner, normalizeTransport, type SubagentTransport } from "./transport.js";
5
6
 
@@ -50,11 +51,38 @@ export interface ManagedAgent {
50
51
  context?: string;
51
52
  contextSourceIds?: string[];
52
53
  contextTruncated?: boolean;
54
+ workspaceMode?: "worktree";
55
+ target?: TargetPolicyAudit;
53
56
  policy?: { inherited: string[]; overridden: string[]; unsupported: string[] };
54
57
  mailbox: AgentMailboxMessage[];
55
58
  currentMailboxMessageIds?: string[];
56
59
  }
57
60
 
61
+ export interface AgentRunInspectionSummary {
62
+ id: string;
63
+ agent: string;
64
+ state: AgentLifecycleState;
65
+ createdAt: number;
66
+ updatedAt: number;
67
+ historyCount: number;
68
+ unreadMessages: number;
69
+ }
70
+
71
+ export interface AgentRunInspectionDetail extends AgentRunInspectionSummary {
72
+ cwd: string;
73
+ thinkingLevel?: SubagentThinkingLevel;
74
+ currentTask?: string;
75
+ error?: string;
76
+ workspaceMode?: "worktree";
77
+ target?: TargetPolicyAudit;
78
+ policy?: { inherited: string[]; overridden: string[]; unsupported: string[] };
79
+ }
80
+
81
+ export interface AgentInspectionCounts {
82
+ activeAgents: number;
83
+ retainedAgents: number;
84
+ }
85
+
58
86
  export interface TurnOutcome {
59
87
  output: string;
60
88
  exitCode: number;
@@ -220,6 +248,8 @@ export class AgentRegistry {
220
248
  context?: string;
221
249
  contextSourceIds?: string[];
222
250
  contextTruncated?: boolean;
251
+ workspaceMode?: "worktree";
252
+ target?: TargetPolicyAudit;
223
253
  }): Promise<ManagedAgent> {
224
254
  if (!input.task.trim()) throw new Error("Subagent tasks cannot be empty");
225
255
  const task = truncateUtf8(input.task, this.maxTaskBytes).text;
@@ -263,6 +293,8 @@ export class AgentRegistry {
263
293
  context: input.context,
264
294
  contextSourceIds: input.contextSourceIds,
265
295
  contextTruncated: input.contextTruncated,
296
+ workspaceMode: input.workspaceMode,
297
+ target: input.target,
266
298
  };
267
299
  this.agents.set(record.id, record);
268
300
  if (parent) {
@@ -500,6 +532,44 @@ export class AgentRegistry {
500
532
  if (shutdownError) throw shutdownError;
501
533
  }
502
534
 
535
+ inspectionCounts(): AgentInspectionCounts {
536
+ let activeAgents = 0;
537
+ let retainedAgents = 0;
538
+ for (const agent of this.agents.values()) {
539
+ if (agent.state === "starting" || agent.state === "running") activeAgents++;
540
+ if (agent.state !== "closed") retainedAgents++;
541
+ }
542
+ return { activeAgents, retainedAgents };
543
+ }
544
+
545
+ listInspection(includeClosed = false): AgentRunInspectionSummary[] {
546
+ return [...this.agents.values()]
547
+ .filter((agent) => includeClosed || agent.state !== "closed")
548
+ .sort((left, right) => left.createdAt - right.createdAt)
549
+ .map((agent) => this.inspectSummary(agent));
550
+ }
551
+
552
+ getInspection(id: string): AgentRunInspectionDetail | undefined {
553
+ const agent = this.agents.get(id);
554
+ if (!agent) return undefined;
555
+ return {
556
+ ...this.inspectSummary(agent),
557
+ cwd: agent.cwd,
558
+ thinkingLevel: agent.thinkingLevel,
559
+ currentTask: agent.currentTask,
560
+ error: agent.error,
561
+ workspaceMode: agent.workspaceMode,
562
+ target: agent.target ? { ...agent.target, trust: { ...agent.target.trust } } : undefined,
563
+ policy: agent.policy
564
+ ? {
565
+ inherited: [...agent.policy.inherited],
566
+ overridden: [...agent.policy.overridden],
567
+ unsupported: [...agent.policy.unsupported],
568
+ }
569
+ : undefined,
570
+ };
571
+ }
572
+
503
573
  list(includeClosed = false, rootId?: string): ManagedAgent[] {
504
574
  return [...this.agents.values()]
505
575
  .filter((agent) => !rootId || agent.rootId === rootId)
@@ -755,6 +825,22 @@ export class AgentRegistry {
755
825
  return next;
756
826
  }
757
827
 
828
+ private inspectSummary(agent: ManagedAgent): AgentRunInspectionSummary {
829
+ let unreadMessages = 0;
830
+ for (const message of agent.mailbox) {
831
+ if (message.readAt === undefined) unreadMessages++;
832
+ }
833
+ return {
834
+ id: agent.id,
835
+ agent: agent.agent,
836
+ state: agent.state,
837
+ createdAt: agent.createdAt,
838
+ updatedAt: agent.updatedAt,
839
+ historyCount: agent.history.length,
840
+ unreadMessages,
841
+ };
842
+ }
843
+
758
844
  private copy(agent: ManagedAgent): ManagedAgent {
759
845
  return {
760
846
  ...agent,
@@ -765,6 +851,7 @@ export class AgentRegistry {
765
851
  : undefined,
766
852
  history: agent.history.map((turn) => ({ ...turn })),
767
853
  mailbox: agent.mailbox.map((message) => ({ ...message })),
854
+ target: agent.target ? { ...agent.target, trust: { ...agent.target.trust } } : undefined,
768
855
  policy: agent.policy
769
856
  ? {
770
857
  inherited: [...agent.policy.inherited],
@@ -0,0 +1,252 @@
1
+ import * as os from "node:os";
2
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
3
+ import {
4
+ keyHint,
5
+ type Theme,
6
+ type ThemeColor,
7
+ type ToolRenderResultOptions,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { Text } from "@earendil-works/pi-tui";
10
+ import { boundedPrivateText, safeTerminalLine } from "./safe-text.js";
11
+
12
+ export const COLLAPSED_LIST_LIMIT = 5;
13
+ export const COLLAPSED_ANSWER_LINES = 3;
14
+
15
+ export interface ToolRendererContext<TArgs> {
16
+ args: TArgs;
17
+ isError: boolean;
18
+ }
19
+
20
+ export type RenderStatus =
21
+ | "starting"
22
+ | "running"
23
+ | "completed"
24
+ | "failed"
25
+ | "cancelled"
26
+ | "interrupted"
27
+ | "idle"
28
+ | "closed"
29
+ | "warning";
30
+
31
+ const STATUS_PRESENTATION: Record<
32
+ RenderStatus,
33
+ { icon: string; label: string; color: ThemeColor }
34
+ > = {
35
+ starting: { icon: "⏳", label: "Starting", color: "warning" },
36
+ running: { icon: "⏳", label: "Running", color: "warning" },
37
+ completed: { icon: "✓", label: "Completed", color: "success" },
38
+ failed: { icon: "✗", label: "Failed", color: "error" },
39
+ cancelled: { icon: "■", label: "Cancelled", color: "warning" },
40
+ interrupted: { icon: "■", label: "Interrupted", color: "warning" },
41
+ idle: { icon: "○", label: "Idle", color: "muted" },
42
+ closed: { icon: "✓", label: "Closed", color: "muted" },
43
+ warning: { icon: "◐", label: "Warning", color: "warning" },
44
+ };
45
+
46
+ export function recordValue(value: unknown): Record<string, unknown> | undefined {
47
+ return value !== null && typeof value === "object" && !Array.isArray(value)
48
+ ? (value as Record<string, unknown>)
49
+ : undefined;
50
+ }
51
+
52
+ export function recordList(value: unknown): Record<string, unknown>[] {
53
+ return Array.isArray(value)
54
+ ? value.flatMap((item) => {
55
+ const record = recordValue(item);
56
+ return record ? [record] : [];
57
+ })
58
+ : [];
59
+ }
60
+
61
+ export function stringValue(value: unknown, fallback = ""): string {
62
+ return typeof value === "string" ? value : fallback;
63
+ }
64
+
65
+ export function numberValue(value: unknown, fallback = 0): number {
66
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
67
+ }
68
+
69
+ export function booleanValue(value: unknown): boolean {
70
+ return value === true;
71
+ }
72
+
73
+ export function safeLine(value: unknown, fallback = "...", maxBytes = 2 * 1024): string {
74
+ if (typeof value !== "string" || !value.trim()) return fallback;
75
+ return safeTerminalLine(value, maxBytes) || fallback;
76
+ }
77
+
78
+ export function safeBlock(value: unknown, fallback = "", maxBytes = 50 * 1024): string {
79
+ if (typeof value !== "string" || !value) return fallback;
80
+ return boundedPrivateText(value, maxBytes);
81
+ }
82
+
83
+ export function previewLines(value: unknown, maxLines = COLLAPSED_ANSWER_LINES): string {
84
+ const text = safeBlock(value, "", 8 * 1024).trim();
85
+ return text.split("\n").slice(0, maxLines).join("\n");
86
+ }
87
+
88
+ export function textResult(result: AgentToolResult<unknown>): string {
89
+ return result.content
90
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
91
+ .join("\n")
92
+ .trim();
93
+ }
94
+
95
+ export function toolHeader(
96
+ theme: Theme,
97
+ toolName: string,
98
+ primary?: unknown,
99
+ metadata: readonly string[] = [],
100
+ ): string {
101
+ let text = theme.fg("toolTitle", theme.bold(`${toolName} `));
102
+ if (primary !== undefined) text += theme.fg("accent", safeLine(primary));
103
+ const safeMetadata = metadata.filter(Boolean).map((item) => safeLine(item, "", 512));
104
+ if (safeMetadata.length > 0) text += theme.fg("muted", ` · ${safeMetadata.join(" · ")}`);
105
+ return text.trimEnd();
106
+ }
107
+
108
+ export function statusBadge(theme: Theme, status: RenderStatus, suffix?: string): string {
109
+ const presentation = STATUS_PRESENTATION[status];
110
+ const label = suffix
111
+ ? `${presentation.label} · ${safeLine(suffix, "", 2 * 1024)}`
112
+ : presentation.label;
113
+ return `${theme.fg(presentation.color, presentation.icon)} ${theme.fg(presentation.color, label)}`;
114
+ }
115
+
116
+ export function renderFallbackResult(
117
+ result: AgentToolResult<unknown>,
118
+ options: ToolRenderResultOptions,
119
+ theme: Theme,
120
+ isError = false,
121
+ ) {
122
+ const status: RenderStatus = isError ? "failed" : options.isPartial ? "running" : "completed";
123
+ const content = safeBlock(textResult(result), "(no output)", 8 * 1024);
124
+ return new Text(`${statusBadge(theme, status)}\n${theme.fg("toolOutput", content)}`, 0, 0);
125
+ }
126
+
127
+ export function expansionHint(): string {
128
+ return keyHint("app.tools.expand", "to expand");
129
+ }
130
+
131
+ export interface RenderActivityItem {
132
+ type: "text" | "toolCall";
133
+ text?: string;
134
+ name?: string;
135
+ args?: Record<string, unknown>;
136
+ }
137
+
138
+ export function projectRenderActivity(value: unknown): RenderActivityItem[] {
139
+ if (!Array.isArray(value)) return [];
140
+ const items: RenderActivityItem[] = [];
141
+ for (const item of value) {
142
+ const record = recordValue(item);
143
+ if (!record) continue;
144
+ if (record.type === "text" && typeof record.text === "string") {
145
+ items.push({ type: "text", text: safeBlock(record.text, "", 1024) });
146
+ continue;
147
+ }
148
+ if (record.type === "toolCall" && typeof record.name === "string") {
149
+ items.push({
150
+ type: "toolCall",
151
+ name: safeLine(record.name, "tool", 256),
152
+ args: recordValue(record.args) ?? {},
153
+ });
154
+ }
155
+ }
156
+ return items;
157
+ }
158
+
159
+ export function renderActivityLines(
160
+ items: readonly RenderActivityItem[],
161
+ theme: Theme,
162
+ limit?: number,
163
+ total = items.length,
164
+ ): string {
165
+ const selected = limit === undefined ? items : items.slice(-limit);
166
+ const lines: string[] = [];
167
+ const skipped = Math.max(0, total - selected.length);
168
+ if (skipped > 0) lines.push(theme.fg("muted", `… ${skipped} earlier activities`));
169
+ for (const item of selected) {
170
+ if (item.type === "text") {
171
+ const text = safeBlock(item.text, "", 1024).trim();
172
+ if (text) lines.push(theme.fg("toolOutput", text));
173
+ } else {
174
+ lines.push(
175
+ theme.fg("muted", "→ ") +
176
+ formatToolActivity(item.name ?? "tool", item.args ?? {}, theme.fg.bind(theme)),
177
+ );
178
+ }
179
+ }
180
+ return lines.join("\n");
181
+ }
182
+
183
+ export function formatToolActivity(
184
+ toolNameValue: unknown,
185
+ argsValue: unknown,
186
+ themeFg: (color: ThemeColor, text: string) => string,
187
+ ): string {
188
+ const toolName = safeLine(toolNameValue, "tool", 256);
189
+ const args = recordValue(argsValue) ?? {};
190
+ const shortenPath = (value: unknown, fallback = ".") => {
191
+ const filePath = safeLine(value, fallback, 2 * 1024);
192
+ const home = os.homedir();
193
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
194
+ };
195
+
196
+ switch (toolName) {
197
+ case "bash": {
198
+ const command = safeLine(args.command, "...", 512);
199
+ return themeFg("muted", "$ ") + themeFg("toolOutput", command);
200
+ }
201
+ case "read": {
202
+ const filePath = shortenPath(args.file_path ?? args.path, "...");
203
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
204
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
205
+ let text = themeFg("accent", filePath);
206
+ if (offset !== undefined || limit !== undefined) {
207
+ const startLine = offset ?? 1;
208
+ const endLine = limit !== undefined ? startLine + limit - 1 : "";
209
+ text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
210
+ }
211
+ return themeFg("muted", "read ") + text;
212
+ }
213
+ case "write": {
214
+ const filePath = shortenPath(args.file_path ?? args.path, "...");
215
+ const content = safeBlock(args.content, "", 2 * 1024);
216
+ const lines = content ? content.split("\n").length : 0;
217
+ return (
218
+ themeFg("muted", "write ") +
219
+ themeFg("accent", filePath) +
220
+ (lines > 1 ? themeFg("dim", ` (${lines} lines)`) : "")
221
+ );
222
+ }
223
+ case "edit":
224
+ return (
225
+ themeFg("muted", "edit ") +
226
+ themeFg("accent", shortenPath(args.file_path ?? args.path, "..."))
227
+ );
228
+ case "ls":
229
+ return themeFg("muted", "ls ") + themeFg("accent", shortenPath(args.path));
230
+ case "find":
231
+ return (
232
+ themeFg("muted", "find ") +
233
+ themeFg("accent", safeLine(args.pattern, "*", 512)) +
234
+ themeFg("dim", ` in ${shortenPath(args.path)}`)
235
+ );
236
+ case "grep":
237
+ return (
238
+ themeFg("muted", "grep ") +
239
+ themeFg("accent", `/${safeLine(args.pattern, "", 512)}/`) +
240
+ themeFg("dim", ` in ${shortenPath(args.path)}`)
241
+ );
242
+ default: {
243
+ let serialized = "{}";
244
+ try {
245
+ serialized = JSON.stringify(args);
246
+ } catch {
247
+ serialized = "{…}";
248
+ }
249
+ return themeFg("accent", toolName) + themeFg("dim", ` ${safeLine(serialized, "{}", 512)}`);
250
+ }
251
+ }
252
+ }