@clanker-code/pi-subagents 0.10.5

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.
Files changed (130) hide show
  1. package/.plans/PLAN-next-changes.md +183 -0
  2. package/.plans/README.md +14 -0
  3. package/AGENTS.md +31 -0
  4. package/CHANGELOG.md +583 -0
  5. package/CLAUDE.md +1 -0
  6. package/LICENSE +21 -0
  7. package/README.md +630 -0
  8. package/RELEASE.md +39 -0
  9. package/dist/abort-resend.d.ts +35 -0
  10. package/dist/abort-resend.js +71 -0
  11. package/dist/agent-details.d.ts +17 -0
  12. package/dist/agent-details.js +22 -0
  13. package/dist/agent-manager.d.ts +132 -0
  14. package/dist/agent-manager.js +493 -0
  15. package/dist/agent-runner.d.ts +165 -0
  16. package/dist/agent-runner.js +732 -0
  17. package/dist/agent-tool-description.d.ts +9 -0
  18. package/dist/agent-tool-description.js +147 -0
  19. package/dist/agent-types.d.ts +60 -0
  20. package/dist/agent-types.js +157 -0
  21. package/dist/context.d.ts +12 -0
  22. package/dist/context.js +56 -0
  23. package/dist/cross-extension-rpc.d.ts +46 -0
  24. package/dist/cross-extension-rpc.js +76 -0
  25. package/dist/custom-agents.d.ts +14 -0
  26. package/dist/custom-agents.js +149 -0
  27. package/dist/default-agents.d.ts +7 -0
  28. package/dist/default-agents.js +119 -0
  29. package/dist/enabled-models.d.ts +49 -0
  30. package/dist/enabled-models.js +145 -0
  31. package/dist/env.d.ts +6 -0
  32. package/dist/env.js +28 -0
  33. package/dist/group-join.d.ts +32 -0
  34. package/dist/group-join.js +116 -0
  35. package/dist/index.d.ts +36 -0
  36. package/dist/index.js +1918 -0
  37. package/dist/invocation-config.d.ts +25 -0
  38. package/dist/invocation-config.js +19 -0
  39. package/dist/memory.d.ts +49 -0
  40. package/dist/memory.js +151 -0
  41. package/dist/model-resolver.d.ts +19 -0
  42. package/dist/model-resolver.js +62 -0
  43. package/dist/notifications.d.ts +6 -0
  44. package/dist/notifications.js +107 -0
  45. package/dist/output-file.d.ts +24 -0
  46. package/dist/output-file.js +86 -0
  47. package/dist/peek.d.ts +37 -0
  48. package/dist/peek.js +121 -0
  49. package/dist/prompts.d.ts +40 -0
  50. package/dist/prompts.js +95 -0
  51. package/dist/schedule-store.d.ts +38 -0
  52. package/dist/schedule-store.js +155 -0
  53. package/dist/schedule.d.ts +109 -0
  54. package/dist/schedule.js +338 -0
  55. package/dist/settings.d.ts +135 -0
  56. package/dist/settings.js +168 -0
  57. package/dist/skill-loader.d.ts +24 -0
  58. package/dist/skill-loader.js +93 -0
  59. package/dist/status-note.d.ts +13 -0
  60. package/dist/status-note.js +24 -0
  61. package/dist/types.d.ts +184 -0
  62. package/dist/types.js +7 -0
  63. package/dist/ui/agent-tool-rendering.d.ts +34 -0
  64. package/dist/ui/agent-tool-rendering.js +154 -0
  65. package/dist/ui/agent-widget-tree.d.ts +33 -0
  66. package/dist/ui/agent-widget-tree.js +130 -0
  67. package/dist/ui/agent-widget.d.ts +156 -0
  68. package/dist/ui/agent-widget.js +408 -0
  69. package/dist/ui/conversation-viewer.d.ts +47 -0
  70. package/dist/ui/conversation-viewer.js +290 -0
  71. package/dist/ui/menu-select.d.ts +20 -0
  72. package/dist/ui/menu-select.js +46 -0
  73. package/dist/ui/schedule-menu.d.ts +16 -0
  74. package/dist/ui/schedule-menu.js +99 -0
  75. package/dist/ui/viewer-keys.d.ts +20 -0
  76. package/dist/ui/viewer-keys.js +17 -0
  77. package/dist/usage.d.ts +50 -0
  78. package/dist/usage.js +49 -0
  79. package/dist/wait.d.ts +10 -0
  80. package/dist/wait.js +37 -0
  81. package/dist/worktree.d.ts +45 -0
  82. package/dist/worktree.js +160 -0
  83. package/docs/design/default-extension-tool-exposure.md +56 -0
  84. package/docs/superpowers/plans/2026-06-19-recursive-subagent-widget.md +600 -0
  85. package/docs/superpowers/specs/2026-06-19-recursive-subagent-widget-design.md +189 -0
  86. package/examples/agent-tool-description.md +45 -0
  87. package/package.json +56 -0
  88. package/reviews/proposal-structured-output-schema.md +135 -0
  89. package/reviews/recursive-subagent-widget-preview-rev2.png +0 -0
  90. package/reviews/recursive-subagent-widget-preview.html +137 -0
  91. package/reviews/recursive-subagent-widget-preview.png +0 -0
  92. package/reviews/subagent-features-comparison.md +350 -0
  93. package/src/abort-resend.ts +75 -0
  94. package/src/agent-details.ts +31 -0
  95. package/src/agent-manager.ts +596 -0
  96. package/src/agent-runner.ts +872 -0
  97. package/src/agent-tool-description.ts +163 -0
  98. package/src/agent-types.ts +189 -0
  99. package/src/context.ts +58 -0
  100. package/src/cross-extension-rpc.ts +122 -0
  101. package/src/custom-agents.ts +160 -0
  102. package/src/default-agents.ts +123 -0
  103. package/src/enabled-models.ts +180 -0
  104. package/src/env.ts +33 -0
  105. package/src/group-join.ts +141 -0
  106. package/src/index.ts +2115 -0
  107. package/src/invocation-config.ts +42 -0
  108. package/src/memory.ts +165 -0
  109. package/src/model-resolver.ts +81 -0
  110. package/src/notifications.ts +120 -0
  111. package/src/output-file.ts +96 -0
  112. package/src/peek.ts +155 -0
  113. package/src/prompts.ts +129 -0
  114. package/src/schedule-store.ts +153 -0
  115. package/src/schedule.ts +365 -0
  116. package/src/settings.ts +289 -0
  117. package/src/skill-loader.ts +102 -0
  118. package/src/status-note.ts +25 -0
  119. package/src/types.ts +195 -0
  120. package/src/ui/agent-tool-rendering.ts +175 -0
  121. package/src/ui/agent-widget-tree.ts +169 -0
  122. package/src/ui/agent-widget.ts +497 -0
  123. package/src/ui/conversation-viewer.ts +297 -0
  124. package/src/ui/menu-select.ts +68 -0
  125. package/src/ui/schedule-menu.ts +105 -0
  126. package/src/ui/viewer-keys.ts +39 -0
  127. package/src/usage.ts +60 -0
  128. package/src/wait.ts +44 -0
  129. package/src/worktree.ts +191 -0
  130. package/vitest.config.ts +25 -0
@@ -0,0 +1,42 @@
1
+ import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
2
+
3
+ interface AgentInvocationParams {
4
+ model?: string;
5
+ thinking?: string;
6
+ max_turns?: number;
7
+ inherit_context?: boolean;
8
+ isolated?: boolean;
9
+ isolation?: IsolationMode;
10
+ }
11
+
12
+ export function resolveAgentInvocationConfig(
13
+ agentConfig: AgentConfig | undefined,
14
+ params: AgentInvocationParams,
15
+ ): {
16
+ modelInput?: string;
17
+ modelFromParams: boolean;
18
+ thinking?: ThinkingLevel;
19
+ maxTurns?: number;
20
+ inheritContext: boolean;
21
+ isolated: boolean;
22
+ isolation?: IsolationMode;
23
+ } {
24
+ return {
25
+ modelInput: agentConfig?.model ?? params.model,
26
+ modelFromParams: agentConfig?.model == null && params.model != null,
27
+ thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
28
+ maxTurns: agentConfig?.maxTurns ?? params.max_turns,
29
+ inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
30
+ isolated: agentConfig?.isolated ?? params.isolated ?? false,
31
+ isolation: agentConfig?.isolation ?? params.isolation,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Resolve the join mode for a spawned agent. This fork runs every agent in the
37
+ * background, so the join mode is always the configured default (no foreground
38
+ * case to suppress it).
39
+ */
40
+ export function resolveJoinMode(defaultJoinMode: JoinMode): JoinMode {
41
+ return defaultJoinMode;
42
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * memory.ts — Persistent agent memory: per-agent memory directories that persist across sessions.
3
+ *
4
+ * Memory scopes:
5
+ * - "user" → ~/.pi/agent-memory/{agent-name}/
6
+ * - "project" → .pi/agent-memory/{agent-name}/
7
+ * - "local" → .pi/agent-memory-local/{agent-name}/
8
+ */
9
+
10
+ import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join, } from "node:path";
13
+ import type { MemoryScope } from "./types.js";
14
+
15
+ /** Maximum lines to read from MEMORY.md */
16
+ const MAX_MEMORY_LINES = 200;
17
+
18
+ /**
19
+ * Returns true if a name contains characters not allowed in agent/skill names.
20
+ * Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
21
+ */
22
+ export function isUnsafeName(name: string): boolean {
23
+ if (!name || name.length > 128) return true;
24
+ return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
25
+ }
26
+
27
+ /**
28
+ * Returns true if the given path is a symlink (defense against symlink attacks).
29
+ */
30
+ export function isSymlink(filePath: string): boolean {
31
+ try {
32
+ return lstatSync(filePath).isSymbolicLink();
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Safely read a file, rejecting symlinks.
40
+ * Returns undefined if the file doesn't exist, is a symlink, or can't be read.
41
+ */
42
+ export function safeReadFile(filePath: string): string | undefined {
43
+ if (!existsSync(filePath)) return undefined;
44
+ if (isSymlink(filePath)) return undefined;
45
+ try {
46
+ return readFileSync(filePath, "utf-8");
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Resolve the memory directory path for a given agent + scope + cwd.
54
+ * Throws if agentName contains path traversal characters.
55
+ */
56
+ export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string {
57
+ if (isUnsafeName(agentName)) {
58
+ throw new Error(`Unsafe agent name for memory directory: "${agentName}"`);
59
+ }
60
+ switch (scope) {
61
+ case "user":
62
+ return join(homedir(), ".pi", "agent-memory", agentName);
63
+ case "project":
64
+ return join(cwd, ".pi", "agent-memory", agentName);
65
+ case "local":
66
+ return join(cwd, ".pi", "agent-memory-local", agentName);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Ensure the memory directory exists, creating it if needed.
72
+ * Refuses to create directories if any component in the path is a symlink
73
+ * to prevent symlink-based directory traversal attacks.
74
+ */
75
+ export function ensureMemoryDir(memoryDir: string): void {
76
+ // If the directory already exists, verify it's not a symlink
77
+ if (existsSync(memoryDir)) {
78
+ if (isSymlink(memoryDir)) {
79
+ throw new Error(`Refusing to use symlinked memory directory: ${memoryDir}`);
80
+ }
81
+ return;
82
+ }
83
+ mkdirSync(memoryDir, { recursive: true });
84
+ }
85
+
86
+ /**
87
+ * Read the first N lines of MEMORY.md from the memory directory, if it exists.
88
+ * Returns undefined if no MEMORY.md exists or if the path is a symlink.
89
+ */
90
+ export function readMemoryIndex(memoryDir: string): string | undefined {
91
+ // Reject symlinked memory directories
92
+ if (isSymlink(memoryDir)) return undefined;
93
+
94
+ const memoryFile = join(memoryDir, "MEMORY.md");
95
+ const content = safeReadFile(memoryFile);
96
+ if (content === undefined) return undefined;
97
+
98
+ const lines = content.split("\n");
99
+ if (lines.length > MAX_MEMORY_LINES) {
100
+ return lines.slice(0, MAX_MEMORY_LINES).join("\n") + "\n... (truncated at 200 lines)";
101
+ }
102
+ return content;
103
+ }
104
+
105
+ /**
106
+ * Build the memory block to inject into the agent's system prompt.
107
+ * Also ensures the memory directory exists (creates it if needed).
108
+ */
109
+ export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
110
+ const memoryDir = resolveMemoryDir(agentName, scope, cwd);
111
+ // Create the memory directory so the agent can immediately write to it
112
+ ensureMemoryDir(memoryDir);
113
+
114
+ const existingMemory = readMemoryIndex(memoryDir);
115
+
116
+ const header = `# Agent Memory
117
+
118
+ You have a persistent memory directory at: ${memoryDir}/
119
+ Memory scope: ${scope}
120
+
121
+ This memory persists across sessions. Use it to build up knowledge over time.`;
122
+
123
+ const memoryContent = existingMemory
124
+ ? `\n\n## Current MEMORY.md\n${existingMemory}`
125
+ : `\n\nNo MEMORY.md exists yet. Create one at ${join(memoryDir, "MEMORY.md")} to start building persistent memory.`;
126
+
127
+ const instructions = `
128
+
129
+ ## Memory Instructions
130
+ - MEMORY.md is an index file — keep it concise (under 200 lines). Lines after 200 are truncated.
131
+ - Store detailed memories in separate files within ${memoryDir}/ and link to them from MEMORY.md.
132
+ - Each memory file should use this frontmatter format:
133
+ \`\`\`markdown
134
+ ---
135
+ name: <memory name>
136
+ description: <one-line description>
137
+ type: <user|feedback|project|reference>
138
+ ---
139
+ <memory content>
140
+ \`\`\`
141
+ - Update or remove memories that become outdated. Check for existing memories before creating duplicates.
142
+ - You have Read, Write, and Edit tools available for managing memory files.`;
143
+
144
+ return header + memoryContent + instructions;
145
+ }
146
+
147
+ /**
148
+ * Build a read-only memory block for agents that lack write/edit tools.
149
+ * Does NOT create the memory directory — agents can only consume existing memory.
150
+ */
151
+ export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
152
+ const memoryDir = resolveMemoryDir(agentName, scope, cwd);
153
+ const existingMemory = readMemoryIndex(memoryDir);
154
+
155
+ const header = `# Agent Memory (read-only)
156
+
157
+ Memory scope: ${scope}
158
+ You have read-only access to memory. You can reference existing memories but cannot create or modify them.`;
159
+
160
+ const memoryContent = existingMemory
161
+ ? `\n\n## Current MEMORY.md\n${existingMemory}`
162
+ : `\n\nNo memory is available yet. Other agents or sessions with write access can create memories for you to consume.`;
163
+
164
+ return header + memoryContent;
165
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Model resolution: exact match ("provider/modelId") with fuzzy fallback.
3
+ */
4
+
5
+ export interface ModelEntry {
6
+ id: string;
7
+ name: string;
8
+ provider: string;
9
+ }
10
+
11
+ export interface ModelRegistry {
12
+ find(provider: string, modelId: string): any;
13
+ getAll(): any[];
14
+ getAvailable?(): any[];
15
+ }
16
+
17
+ /**
18
+ * Resolve a model string to a Model instance.
19
+ * Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
20
+ * Returns the Model on success, or an error message string on failure.
21
+ */
22
+ export function resolveModel(
23
+ input: string,
24
+ registry: ModelRegistry,
25
+ ): any | string {
26
+ // Available models (those with auth configured)
27
+ const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
28
+ const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
29
+
30
+ // 1. Exact match: "provider/modelId" — only if available (has auth)
31
+ const slashIdx = input.indexOf("/");
32
+ if (slashIdx !== -1) {
33
+ const provider = input.slice(0, slashIdx);
34
+ const modelId = input.slice(slashIdx + 1);
35
+ if (availableSet.has(input.toLowerCase())) {
36
+ const found = registry.find(provider, modelId);
37
+ if (found) return found;
38
+ }
39
+ }
40
+
41
+ // 2. Fuzzy match against available models
42
+ const query = input.toLowerCase();
43
+
44
+ // Score each model: prefer exact id match > id contains > name contains > provider+id contains
45
+ let bestMatch: ModelEntry | undefined;
46
+ let bestScore = 0;
47
+
48
+ for (const m of all) {
49
+ const id = m.id.toLowerCase();
50
+ const name = m.name.toLowerCase();
51
+ const full = `${m.provider}/${m.id}`.toLowerCase();
52
+
53
+ let score = 0;
54
+ if (id === query || full === query) {
55
+ score = 100; // exact
56
+ } else if (id.includes(query) || full.includes(query)) {
57
+ score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
58
+ } else if (name.includes(query)) {
59
+ score = 40 + (query.length / name.length) * 20;
60
+ } else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
61
+ score = 20; // all parts present somewhere
62
+ }
63
+
64
+ if (score > bestScore) {
65
+ bestScore = score;
66
+ bestMatch = m;
67
+ }
68
+ }
69
+
70
+ if (bestMatch && bestScore >= 20) {
71
+ const found = registry.find(bestMatch.provider, bestMatch.id);
72
+ if (found) return found;
73
+ }
74
+
75
+ // 3. No match — list available models
76
+ const modelList = all
77
+ .map(m => ` ${m.provider}/${m.id}`)
78
+ .sort()
79
+ .join("\n");
80
+ return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
81
+ }
@@ -0,0 +1,120 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { getStatusNote } from "./status-note.js";
4
+ import type { AgentRecord, NotificationDetails } from "./types.js";
5
+ import type { AgentActivity } from "./ui/agent-widget.js";
6
+ import { formatMs, formatTokens, formatTurns } from "./ui/agent-widget.js";
7
+ import { getLifetimeTotal, getSessionContextPercent } from "./usage.js";
8
+
9
+ function getStatusLabel(status: string, error?: string): string {
10
+ switch (status) {
11
+ case "error": return `Error: ${error ?? "unknown"}`;
12
+ case "aborted": return "Aborted (max turns exceeded)";
13
+ case "steered": return "Wrapped up (turn limit)";
14
+ case "stopped": return "Stopped";
15
+ default: return "Done";
16
+ }
17
+ }
18
+
19
+ function escapeXml(s: string): string {
20
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
21
+ }
22
+
23
+ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string {
24
+ const status = getStatusLabel(record.status, record.error);
25
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
26
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
27
+ const contextPercent = getSessionContextPercent(record.session);
28
+ const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
29
+ const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
30
+
31
+ const resultPreview = record.result
32
+ ? record.result.length > resultMaxLen
33
+ ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
34
+ : record.result
35
+ : "No output.";
36
+ const fullOutputInstruction = record.outputFile
37
+ ? `Read the full output/log with get_subagent_result for agent ${record.id}, or inspect the transcript file: ${record.outputFile}`
38
+ : `Read the full output/log with get_subagent_result for agent ${record.id}.`;
39
+
40
+ return [
41
+ `<task-notification>`,
42
+ `<task-id>${record.id}</task-id>`,
43
+ record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
44
+ record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
45
+ `<status>${escapeXml(status)}</status>`,
46
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
47
+ `<result>${escapeXml(resultPreview)}</result>`,
48
+ `<full-output>${escapeXml(fullOutputInstruction)}</full-output>`,
49
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
50
+ `</task-notification>`,
51
+ ].filter(Boolean).join("\n");
52
+ }
53
+
54
+ export function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
55
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
56
+
57
+ return {
58
+ id: record.id,
59
+ description: record.description,
60
+ status: record.status,
61
+ toolUses: record.toolUses,
62
+ turnCount: activity?.turnCount ?? 0,
63
+ maxTurns: activity?.maxTurns,
64
+ totalTokens,
65
+ durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
66
+ outputFile: record.outputFile,
67
+ error: record.error,
68
+ resultPreview: record.result
69
+ ? record.result.length > resultMaxLen
70
+ ? record.result.slice(0, resultMaxLen) + "…"
71
+ : record.result
72
+ : "No output.",
73
+ };
74
+ }
75
+
76
+ export function registerSubagentNotificationRenderer(pi: ExtensionAPI): void {
77
+ pi.registerMessageRenderer<NotificationDetails>(
78
+ "subagent-notification",
79
+ (message, { expanded }, theme) => {
80
+ const d = message.details;
81
+ if (!d) return undefined;
82
+
83
+ function renderOne(d: NotificationDetails): string {
84
+ const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
85
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
86
+ const statusText = isError ? d.status
87
+ : d.status === "steered" ? "completed (steered)"
88
+ : "completed";
89
+
90
+ let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
91
+ const parts: string[] = [];
92
+ if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
93
+ if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
94
+ if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
95
+ if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
96
+ if (parts.length) {
97
+ line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
98
+ }
99
+
100
+ if (expanded) {
101
+ const lines = d.resultPreview.split("\n").slice(0, 30);
102
+ for (const resultLine of lines) line += "\n" + theme.fg("dim", ` ${resultLine}`);
103
+ } else {
104
+ const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
105
+ line += "\n " + theme.fg("dim", `⎿ ${preview}`);
106
+ }
107
+
108
+ const fullOutputHint = d.outputFile
109
+ ? `full output: get_subagent_result ${d.id} or transcript: ${d.outputFile}`
110
+ : `full output: get_subagent_result ${d.id}`;
111
+ line += "\n " + theme.fg("muted", fullOutputHint);
112
+
113
+ return line;
114
+ }
115
+
116
+ const all = [d, ...(d.others ?? [])];
117
+ return new Text(all.map(renderOne).join("\n"), 0, 0);
118
+ },
119
+ );
120
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * output-file.ts — Streaming JSONL output file for agent transcripts.
3
+ *
4
+ * Creates a per-agent output file that streams conversation turns as JSONL,
5
+ * matching Claude Code's task output file format.
6
+ */
7
+
8
+ import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
12
+
13
+ /**
14
+ * Encode a cwd path as a filesystem-safe directory name. Handles:
15
+ * - POSIX: "/home/user/project" → "home-user-project"
16
+ * - Windows: "C:\Users\foo\project" → "Users-foo-project"
17
+ * - UNC: "\\\\server\\share\\project" → "server-share-project"
18
+ */
19
+ export function encodeCwd(cwd: string): string {
20
+ return cwd
21
+ .replace(/[/\\]/g, "-") // both separators → dash
22
+ .replace(/^[A-Za-z]:-/, "") // strip Windows drive prefix ("C:-")
23
+ .replace(/^-+/, ""); // strip leading dashes (POSIX root, UNC)
24
+ }
25
+
26
+ /** Create the output file path, ensuring the directory exists.
27
+ * Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
28
+ export function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string {
29
+ const encoded = encodeCwd(cwd);
30
+ const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
31
+ mkdirSync(root, { recursive: true, mode: 0o700 });
32
+ // chmod is a no-op on Windows and throws on some Windows filesystems.
33
+ // On Unix we still want to enforce 0o700 past umask, so only swallow on Windows.
34
+ try {
35
+ chmodSync(root, 0o700);
36
+ } catch (err) {
37
+ if (process.platform !== "win32") throw err;
38
+ }
39
+ const dir = join(root, encoded, sessionId, "tasks");
40
+ mkdirSync(dir, { recursive: true });
41
+ return join(dir, `${agentId}.output`);
42
+ }
43
+
44
+ /** Write the initial user prompt entry. */
45
+ export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void {
46
+ const entry = {
47
+ isSidechain: true,
48
+ agentId,
49
+ type: "user",
50
+ message: { role: "user", content: prompt },
51
+ timestamp: new Date().toISOString(),
52
+ cwd,
53
+ };
54
+ writeFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
55
+ }
56
+
57
+ /**
58
+ * Subscribe to session events and flush new messages to the output file on each turn_end.
59
+ * Returns a cleanup function that does a final flush and unsubscribes.
60
+ */
61
+ export function streamToOutputFile(
62
+ session: AgentSession,
63
+ path: string,
64
+ agentId: string,
65
+ cwd: string,
66
+ ): () => void {
67
+ let writtenCount = 1; // initial user prompt already written
68
+
69
+ const flush = () => {
70
+ const messages = session.messages;
71
+ while (writtenCount < messages.length) {
72
+ const msg = messages[writtenCount];
73
+ const entry = {
74
+ isSidechain: true,
75
+ agentId,
76
+ type: msg.role === "assistant" ? "assistant" : msg.role === "user" ? "user" : "toolResult",
77
+ message: msg,
78
+ timestamp: new Date().toISOString(),
79
+ cwd,
80
+ };
81
+ try {
82
+ appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
83
+ } catch { /* ignore write errors */ }
84
+ writtenCount++;
85
+ }
86
+ };
87
+
88
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
89
+ if (event.type === "turn_end") flush();
90
+ });
91
+
92
+ return () => {
93
+ flush();
94
+ unsubscribe();
95
+ };
96
+ }
package/src/peek.ts ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * peek.ts — Lightweight tail/filter view of an agent's result or streaming
3
+ * output file for `get_subagent_result`'s `peek` parameter.
4
+ *
5
+ * Design:
6
+ * - Source precedence: streaming output file (best for running agents) → record
7
+ * result (finished agents) → "no output yet".
8
+ * - The output file is JSONL (one entry per message). We extract human-readable
9
+ * text lines (assistant text + tool-result text) so a peek shows useful
10
+ * progress, not raw JSON.
11
+ * - Semantics: filter-then-tail. If `regex` is given, only matching source lines
12
+ * are kept; then `after` (return all lines past an index) or `lines` (last N)
13
+ * is applied. Line numbers always refer to the FULL source so callers can use
14
+ * `after` for incremental updates without missing anything.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import type { AgentRecord } from "./types.js";
19
+
20
+ export interface PeekOptions {
21
+ /** Number of trailing lines to return. Default 20. Minimum 1. */
22
+ lines?: number;
23
+ /** Optional regex filter applied to each source line (filter-then-tail). */
24
+ regex?: string;
25
+ /** Return all source lines after this 1-based line number (matches the [N] prefixes in peek output). Use the last line number you saw to fetch only new lines. Overrides `lines`. */
26
+ after?: number;
27
+ }
28
+
29
+ export interface PeekResult {
30
+ /** The formatted peek text (with line-number prefixes and a header). */
31
+ text: string;
32
+ /** Number of source lines available (before filtering). */
33
+ totalLines: number;
34
+ /** Whether the source was the output file (live) or the result. */
35
+ source: "outputFile" | "result";
36
+ }
37
+
38
+ /** Default number of tail lines when neither `after` nor `lines` is given. */
39
+ const DEFAULT_LINES = 20;
40
+
41
+ /**
42
+ * Produce a peek view of an agent's output. Returns null when there is no
43
+ * source content at all (the caller renders a "no output yet" message).
44
+ */
45
+ export function peekAgentOutput(record: AgentRecord, opts: PeekOptions = {}): PeekResult | null {
46
+ const lines = readSourceLines(record);
47
+ if (lines.length === 0) return null;
48
+
49
+ const regex = opts.regex ? compileRegex(opts.regex) : undefined;
50
+ const after = typeof opts.after === "number" ? opts.after : -1;
51
+ const tail = typeof opts.lines === "number" && opts.lines >= 1 ? opts.lines : DEFAULT_LINES;
52
+
53
+ // Index each source line with its original position (1-based for display).
54
+ const indexed = lines.map((text, i) => ({ no: i + 1, text }));
55
+
56
+ // Filter-then-select.
57
+ const filtered = regex ? indexed.filter((l) => regex.test(l.text)) : indexed;
58
+ const selected =
59
+ after >= 0
60
+ ? filtered.filter((l) => l.no > after)
61
+ : filtered.slice(-tail);
62
+
63
+ const totalLines = lines.length;
64
+ const isRunning = record.status === "running" || record.status === "queued";
65
+ const source: PeekResult["source"] =
66
+ isRunning && record.outputFile && existsSync(record.outputFile) ? "outputFile" : "result";
67
+
68
+ const header = buildHeader(opts, selected.length, totalLines, source);
69
+ const body = selected.map((l) => `[${l.no}] ${l.text}`).join("\n");
70
+
71
+ return { text: `${header}\n\n${body}`, totalLines, source };
72
+ }
73
+
74
+ /** Read the most useful text lines from the agent's output. */
75
+ function readSourceLines(record: AgentRecord): string[] {
76
+ const isRunning = record.status === "running" || record.status === "queued";
77
+ const outputFileLines =
78
+ record.outputFile && existsSync(record.outputFile) ? parseOutputFileLines(record.outputFile) : [];
79
+
80
+ // While running, the live output file is the only source of progress.
81
+ if (isRunning && outputFileLines.length > 0) return outputFileLines;
82
+
83
+ // Finished (or no live file): prefer the clean result text.
84
+ if (record.result?.trim()) {
85
+ return record.result.split("\n");
86
+ }
87
+
88
+ // Last resort: the output file (e.g. agent errored before producing a result).
89
+ return outputFileLines;
90
+ }
91
+
92
+ /**
93
+ * Parse the JSONL output file and extract human-readable text lines.
94
+ * Each entry has `{ type, message: { role, content } }`. We pull assistant text
95
+ * and tool-result text so a peek reflects actual progress.
96
+ */
97
+ function parseOutputFileLines(path: string): string[] {
98
+ let raw: string;
99
+ try {
100
+ raw = readFileSync(path, "utf-8");
101
+ } catch {
102
+ return [];
103
+ }
104
+ const out: string[] = [];
105
+ for (const line of raw.split("\n")) {
106
+ const trimmed = line.trim();
107
+ if (!trimmed) continue;
108
+ let entry: any;
109
+ try {
110
+ entry = JSON.parse(trimmed);
111
+ } catch {
112
+ continue;
113
+ }
114
+ const content = entry?.message?.content;
115
+ if (!Array.isArray(content)) {
116
+ // Some entries may carry a plain string content.
117
+ if (typeof content === "string" && content.trim()) out.push(content.trim());
118
+ continue;
119
+ }
120
+ for (const block of content) {
121
+ if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) {
122
+ out.push(block.text.trimEnd());
123
+ }
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+
129
+ function compileRegex(pattern: string): RegExp {
130
+ // Anchor-free; case-sensitive by default. Invalid patterns fall back to a
131
+ // substring literal match so a bad regex never throws into the tool result.
132
+ try {
133
+ return new RegExp(pattern);
134
+ } catch {
135
+ return new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
136
+ }
137
+ }
138
+
139
+ function buildHeader(
140
+ opts: PeekOptions,
141
+ shown: number,
142
+ total: number,
143
+ source: PeekResult["source"],
144
+ ): string {
145
+ const parts: string[] = [];
146
+ if (typeof opts.after === "number") {
147
+ parts.push(`after line number ${opts.after}`);
148
+ } else {
149
+ const n = typeof opts.lines === "number" && opts.lines >= 1 ? opts.lines : DEFAULT_LINES;
150
+ parts.push(`last ${n} lines`);
151
+ }
152
+ if (opts.regex) parts.push(`filtered by regex /${opts.regex}/`);
153
+ parts.push(`of ${total} total (${source === "outputFile" ? "live output file" : "result"})`);
154
+ return `Showing ${shown} ${parts.join(", ")}. Line numbers index the full source.`;
155
+ }