@ryan_nookpi/pi-extension-subagent 0.4.10 → 0.5.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/activity.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Throttled `subagent-activity` custom entries.
3
+ *
4
+ * Lets host UIs (e.g. Picky) observe live activity of headless subagent runs
5
+ * by appending change-driven, throttled entries to the parent session JSONL.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import type { CommandRunState } from "./types.js";
10
+
11
+ export const SUBAGENT_ACTIVITY_CUSTOM_TYPE = "subagent-activity";
12
+ export const SUBAGENT_ACTIVITY_SCHEMA_VERSION = 1;
13
+ export const ACTIVITY_MIN_INTERVAL_MS = 2500;
14
+ export const ACTIVITY_LAST_LINE_MAX_CHARS = 160;
15
+
16
+ export interface ActivitySnapshot {
17
+ status: CommandRunState["status"];
18
+ runId: number;
19
+ agent: string;
20
+ batchId?: string;
21
+ pipelineId?: string;
22
+ pipelineStepIndex?: number;
23
+ lastToolName?: string;
24
+ toolCallCount: number;
25
+ lastLine?: string;
26
+ contextTokens?: number;
27
+ }
28
+
29
+ export interface ActivityPayload {
30
+ runId: number;
31
+ agent: string;
32
+ batchId?: string;
33
+ pipelineId?: string;
34
+ pipelineStepIndex?: number;
35
+ lastToolName?: string;
36
+ toolCallCount: number;
37
+ lastLine?: string;
38
+ contextTokens?: number;
39
+ }
40
+
41
+ export interface ActivityThrottleState {
42
+ lastEmittedAt: number;
43
+ lastEmittedToolName?: string;
44
+ lastEmittedToolCallCount?: number;
45
+ }
46
+
47
+ export function createActivityThrottleState(): ActivityThrottleState {
48
+ return { lastEmittedAt: 0 };
49
+ }
50
+
51
+ const ANSI_PATTERN =
52
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences use control characters by definition.
53
+ /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[\u0000-\u0008\u000b-\u001f\u007f]/g;
54
+
55
+ /** Strip ANSI sequences, collapse to a single line, and cap length. */
56
+ export function normalizeActivityLine(text: string | undefined): string | undefined {
57
+ if (!text) return undefined;
58
+ const line = text
59
+ .replace(/\r\n?/g, "\n")
60
+ .replace(ANSI_PATTERN, "")
61
+ .split("\n")
62
+ .map((part) => part.trim())
63
+ .filter(Boolean)
64
+ .pop();
65
+ if (!line) return undefined;
66
+ return line.length > ACTIVITY_LAST_LINE_MAX_CHARS ? line.slice(0, ACTIVITY_LAST_LINE_MAX_CHARS) : line;
67
+ }
68
+
69
+ /**
70
+ * Change-driven throttle policy. Returns a payload to emit, or null.
71
+ *
72
+ * Emits only while the run is `running`, only when `lastToolName` or
73
+ * `toolCallCount` changed since the last emission, and at most once per
74
+ * `ACTIVITY_MIN_INTERVAL_MS` per run. A change suppressed by the interval
75
+ * stays pending and emits on a later evaluation.
76
+ */
77
+ export function evaluateActivityEmission(
78
+ state: ActivityThrottleState,
79
+ snapshot: ActivitySnapshot,
80
+ now: number,
81
+ ): ActivityPayload | null {
82
+ if (snapshot.status !== "running") return null;
83
+ // Continuations reset toolCalls to 0 while a stale lastToolName may linger
84
+ // on the run state, so a positive count is required regardless of the name.
85
+ if (snapshot.toolCallCount <= 0) return null;
86
+
87
+ const changed =
88
+ snapshot.lastToolName !== state.lastEmittedToolName || snapshot.toolCallCount !== state.lastEmittedToolCallCount;
89
+ if (!changed) return null;
90
+ if (state.lastEmittedAt !== 0 && now - state.lastEmittedAt < ACTIVITY_MIN_INTERVAL_MS) return null;
91
+
92
+ state.lastEmittedAt = now;
93
+ state.lastEmittedToolName = snapshot.lastToolName;
94
+ state.lastEmittedToolCallCount = snapshot.toolCallCount;
95
+
96
+ const payload: ActivityPayload = {
97
+ runId: snapshot.runId,
98
+ agent: snapshot.agent,
99
+ toolCallCount: snapshot.toolCallCount,
100
+ };
101
+ if (snapshot.batchId) payload.batchId = snapshot.batchId;
102
+ if (snapshot.pipelineId) payload.pipelineId = snapshot.pipelineId;
103
+ if (snapshot.pipelineStepIndex != null) payload.pipelineStepIndex = snapshot.pipelineStepIndex;
104
+ if (snapshot.lastToolName) payload.lastToolName = snapshot.lastToolName;
105
+ const lastLine = normalizeActivityLine(snapshot.lastLine);
106
+ if (lastLine) payload.lastLine = lastLine;
107
+ if (snapshot.contextTokens != null && snapshot.contextTokens > 0) payload.contextTokens = snapshot.contextTokens;
108
+ return payload;
109
+ }
110
+
111
+ export function snapshotFromRunState(runState: CommandRunState): ActivitySnapshot {
112
+ return {
113
+ status: runState.status,
114
+ runId: runState.id,
115
+ agent: runState.agent,
116
+ batchId: runState.batchId,
117
+ pipelineId: runState.pipelineId,
118
+ pipelineStepIndex: runState.pipelineStepIndex,
119
+ lastToolName: runState.lastToolName,
120
+ toolCallCount: runState.toolCalls,
121
+ lastLine: runState.lastLine,
122
+ contextTokens: runState.usage?.contextTokens,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Best-effort recorder called from run update handlers. Never throws;
128
+ * activity logging must not affect run execution or completion delivery.
129
+ */
130
+ export function createRunActivityRecorder(
131
+ pi: Pick<ExtensionAPI, "appendEntry">,
132
+ runState: CommandRunState,
133
+ ): () => void {
134
+ const throttleState = createActivityThrottleState();
135
+ return () => {
136
+ try {
137
+ const payload = evaluateActivityEmission(throttleState, snapshotFromRunState(runState), Date.now());
138
+ if (!payload) return;
139
+ pi.appendEntry(SUBAGENT_ACTIVITY_CUSTOM_TYPE, {
140
+ schemaVersion: SUBAGENT_ACTIVITY_SCHEMA_VERSION,
141
+ recordedAt: new Date().toISOString(),
142
+ ...payload,
143
+ });
144
+ } catch {
145
+ // Session teardown may invalidate the old extension runtime.
146
+ }
147
+ };
148
+ }
package/agents.ts CHANGED
@@ -140,25 +140,30 @@ function loadAgentsFromDir(dir: string, source: "user" | "project", options: Loa
140
140
  continue;
141
141
  }
142
142
 
143
- const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
144
- if (!frontmatter.name || !frontmatter.description) continue;
145
-
146
- const tools = normalizeTools(frontmatter.tools, format);
147
- const model = normalizeModel(frontmatter.model, format);
148
- const thinking = normalizeThinkingLevel(frontmatter.thinking);
149
- const runtime: AgentRuntime = frontmatter.runtime === "claude" ? "claude" : "pi";
150
-
151
- agents.push({
152
- name: frontmatter.name,
153
- description: frontmatter.description,
154
- tools,
155
- model,
156
- thinking,
157
- systemPrompt: attachCommonSubagentRule(body, runtime),
158
- source,
159
- filePath,
160
- runtime,
161
- });
143
+ try {
144
+ const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
145
+ if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") continue;
146
+ if (!frontmatter.name || !frontmatter.description) continue;
147
+
148
+ const tools = normalizeTools(frontmatter.tools, format);
149
+ const model = normalizeModel(typeof frontmatter.model === "string" ? frontmatter.model : undefined, format);
150
+ const thinking = normalizeThinkingLevel(
151
+ typeof frontmatter.thinking === "string" ? frontmatter.thinking : undefined,
152
+ );
153
+ const runtime: AgentRuntime = frontmatter.runtime === "claude" ? "claude" : "pi";
154
+
155
+ agents.push({
156
+ name: frontmatter.name,
157
+ description: frontmatter.description,
158
+ tools,
159
+ model,
160
+ thinking,
161
+ systemPrompt: attachCommonSubagentRule(body, runtime),
162
+ source,
163
+ filePath,
164
+ runtime,
165
+ });
166
+ } catch {}
162
167
  }
163
168
 
164
169
  return agents;
package/commands.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import * as fs from "node:fs";
10
10
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
11
  import { Container, Key, matchesKey, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
+ import { createRunActivityRecorder } from "./activity.js";
12
13
  import { discoverAgents } from "./agents.js";
13
14
  import { loadSubagentConfig } from "./config.js";
14
15
  import {
@@ -1249,6 +1250,8 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1249
1250
  runState.model = undefined;
1250
1251
  runState.retryCount = 0;
1251
1252
  runState.lastRetryReason = undefined;
1253
+ runState.errorClass = undefined;
1254
+ runState.autoAbortReason = undefined;
1252
1255
  runState.removed = false;
1253
1256
  runState.deliveryMode = deliveryMode;
1254
1257
  runState.turnCount = Math.max(DEFAULT_TURN_COUNT, runState.turnCount || DEFAULT_TURN_COUNT) + 1;
@@ -1399,6 +1402,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1399
1402
  }, RUN_TICK_INTERVAL_MS);
1400
1403
 
1401
1404
  let claudeCheckpointSent = !!runState.claudeSessionId;
1405
+ const recordActivity = hiddenFromMain ? () => {} : createRunActivityRecorder(pi, runState);
1402
1406
  void (async () => {
1403
1407
  try {
1404
1408
  const { result, retryCount } = await invokeWithAutoRetry({
@@ -1428,6 +1432,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
1428
1432
  const current = partial.details?.results?.[0];
1429
1433
  if (!current) return;
1430
1434
  updateRunFromResult(runState, current);
1435
+ recordActivity();
1431
1436
  if (!claudeCheckpointSent && runState.claudeSessionId) {
1432
1437
  claudeCheckpointSent = true;
1433
1438
  if (!hiddenFromMain) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-extension-subagent",
3
- "version": "0.4.10",
3
+ "version": "0.5.1",
4
4
  "description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,6 +20,7 @@
20
20
  ],
21
21
  "files": [
22
22
  "above-widget.ts",
23
+ "activity.ts",
23
24
  "agents.ts",
24
25
  "claude-args.ts",
25
26
  "claude-sdk-runner.ts",
package/tool-execute.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import * as fs from "node:fs";
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import { createRunActivityRecorder } from "./activity.js";
11
12
  import { discoverAgents } from "./agents.js";
12
13
  import { parseSubagentToolCommand, SUBAGENT_CLI_HELP_TEXT } from "./cli.js";
13
14
  import {
@@ -1253,6 +1254,10 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1253
1254
  runState.lastOutput = "";
1254
1255
  runState.usage = undefined;
1255
1256
  runState.model = undefined;
1257
+ runState.retryCount = 0;
1258
+ runState.lastRetryReason = undefined;
1259
+ runState.errorClass = undefined;
1260
+ runState.autoAbortReason = undefined;
1256
1261
  runState.removed = false;
1257
1262
  runState.turnCount = Math.max(DEFAULT_TURN_COUNT, runState.turnCount || DEFAULT_TURN_COUNT) + 1;
1258
1263
  runState.contextMode = runState.contextMode ?? (config.inheritMainContext ? "main" : "sub");
@@ -1314,6 +1319,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1314
1319
 
1315
1320
  function launchRunInBackground(runState: CommandRunState, taskForAgent: string): Promise<FinalizedRun> {
1316
1321
  let claudeCheckpointSent = !!runState.claudeSessionId;
1322
+ const recordActivity = createRunActivityRecorder(pi, runState);
1317
1323
  return enqueueSubagentInvocation(() =>
1318
1324
  runSingleAgent(
1319
1325
  ctx.cwd,
@@ -1327,6 +1333,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1327
1333
  const current = partial.details?.results?.[0];
1328
1334
  if (!current) return;
1329
1335
  updateRunFromResult(runState, current);
1336
+ recordActivity();
1330
1337
  if (shouldRunAsync && !claudeCheckpointSent && runState.claudeSessionId) {
1331
1338
  claudeCheckpointSent = true;
1332
1339
  pi.sendMessage(buildRunStartMessage(runState, "started"), {
@@ -1976,7 +1983,14 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
1976
1983
  pipeline.stepRunIds.push(runState.id);
1977
1984
  chainLaunches.push(toLaunchSummary(runState, "chain"));
1978
1985
 
1979
- const finalized = await launchRunInBackground(runState, taskForAgent);
1986
+ let finalized: FinalizedRun;
1987
+ try {
1988
+ finalized = await launchRunInBackground(runState, taskForAgent);
1989
+ } catch (error: unknown) {
1990
+ finalized = finalizeRunError(runState, error);
1991
+ } finally {
1992
+ clearRunAbortState(runState);
1993
+ }
1980
1994
  if (runState.removed) {
1981
1995
  terminalStatus = finalized.isError ? "error" : "stopped";
1982
1996
  pipeline.stepResults.push({
@@ -88,12 +88,10 @@ export const CLAUDE_MODEL_ALIAS_MAP: Record<string, string> = {
88
88
  * Normalize a comma-separated tool list according to the given format.
89
89
  * For "claude" format, maps Claude tool names to pi equivalents.
90
90
  */
91
- export function normalizeTools(rawTools: string | undefined, format: "pi" | "claude"): string[] | undefined {
92
- if (!rawTools) return undefined;
93
-
94
- const parsed = rawTools
95
- .split(",")
96
- .map((t) => t.trim())
91
+ export function normalizeTools(rawTools: unknown, format: "pi" | "claude"): string[] | undefined {
92
+ const parsed = (Array.isArray(rawTools) ? rawTools : typeof rawTools === "string" ? rawTools.split(",") : [])
93
+ .filter((tool): tool is string => typeof tool === "string")
94
+ .map((tool) => tool.trim())
97
95
  .filter(Boolean);
98
96
 
99
97
  if (parsed.length === 0) return undefined;