@tintinweb/pi-subagents 0.4.8 → 0.4.10

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,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-node@v4
15
+ with:
16
+ node-version: 20
17
+ cache: npm
18
+ - run: npm ci
19
+ - run: npm run lint
20
+ - run: npm run typecheck
21
+ - run: npm run test
package/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.10] - 2026-03-18
9
+
10
+ ### Changed
11
+ - **Default max turns is now unlimited** — subagents no longer have a 50-turn default cap. The default is unlimited (no turn limit), matching Claude Code's main loop behavior. Users can still set explicit limits per-agent via `max_turns` frontmatter or the Agent tool parameter, or globally via `/agents` → Settings (`0` = unlimited).
12
+ - **Live turn counter** — all agents now show a live turn count in the widget, inline result, and completion notification. With a turn limit: `⟳5≤30` (5 of 30 turns). Without: `⟳5`. Updates in real time as turns progress.
13
+
14
+ ### Added
15
+ - **Biome linting** — added [Biome](https://biomejs.dev/) for correctness linting (unused imports, suspicious patterns). Style rules disabled. Run `npm run lint` to check, `npm run lint:fix` to auto-fix.
16
+ - **CI workflow** — GitHub Actions runs lint, typecheck, and tests on push to master and PRs.
17
+
18
+ ### Fixed
19
+ - **Env test CI failure** — `detectEnv` test assumed a branch name exists, but CI checks out detached HEAD. Split into separate tests for repo detection and branch detection with a controlled temp repo.
20
+
21
+ ## [0.4.9] - 2026-03-18
22
+
23
+ ### Fixed
24
+ - **Conversation viewer crash in narrow terminals** ([#7](https://github.com/tintinweb/pi-subagents/issues/7)) — `buildContentLines()` in the live conversation viewer could return lines wider than the terminal when `wrapTextWithAnsi()` misjudged visible width on ANSI-heavy input (e.g. tool output with embedded escape codes, long URLs, wide tables). All content lines are now clamped with `truncateToWidth()` before returning. Same class of bug as the widget fix in v0.2.7, different component.
25
+
26
+ ### Added
27
+ - **Conversation viewer width-safety tests** — 17 tests covering `render()` and `buildContentLines()` across varied content (plain text, ANSI codes, unicode, tables, long URLs, narrow terminals). Includes mock-based regression tests that simulate upstream `wrapTextWithAnsi` returning overwidth lines, ensuring the safety net catches them.
28
+
8
29
  ## [0.4.8] - 2026-03-18
9
30
 
10
31
  ### Added
@@ -299,6 +320,7 @@ Initial release.
299
320
  - **Thinking level** — per-agent extended thinking control
300
321
  - **`/agent` and `/agents` commands**
301
322
 
323
+ [0.4.9]: https://github.com/tintinweb/pi-subagents/compare/v0.4.8...v0.4.9
302
324
  [0.4.8]: https://github.com/tintinweb/pi-subagents/compare/v0.4.7...v0.4.8
303
325
  [0.4.7]: https://github.com/tintinweb/pi-subagents/compare/v0.4.6...v0.4.7
304
326
  [0.4.6]: https://github.com/tintinweb/pi-subagents/compare/v0.4.5...v0.4.6
package/README.md CHANGED
@@ -64,9 +64,9 @@ The extension renders a persistent widget above the editor showing all active ag
64
64
 
65
65
  ```
66
66
  ● Agents
67
- ├─ ⠹ Agent Refactor auth module · 5 tool uses · 33.8k token · 12.3s
67
+ ├─ ⠹ Agent Refactor auth module · 5≤30 · 5 tool uses · 33.8k token · 12.3s
68
68
  │ ⎿ editing 2 files…
69
- ├─ ⠹ Explore Find auth files · 3 tool uses · 12.4k token · 4.1s
69
+ ├─ ⠹ Explore Find auth files · 3 · 3 tool uses · 12.4k token · 4.1s
70
70
  │ ⎿ searching…
71
71
  └─ 2 queued
72
72
  ```
@@ -75,12 +75,12 @@ Individual agent results render Claude Code-style in the conversation:
75
75
 
76
76
  | State | Example |
77
77
  |-------|---------|
78
- | **Running** | `⠹ 3 tool uses · 12.4k token` / `⎿ searching, reading 3 files…` |
79
- | **Completed** | `✓ 5 tool uses · 33.8k token · 12.3s` / `⎿ Done` |
80
- | **Wrapped up** | `✓ 50 tool uses · 89.1k token · 45.2s` / `⎿ Wrapped up (turn limit)` |
81
- | **Stopped** | `■ 3 tool uses · 12.4k token` / `⎿ Stopped` |
82
- | **Error** | `✗ 3 tool uses · 12.4k token` / `⎿ Error: timeout` |
83
- | **Aborted** | `✗ 55 tool uses · 102.3k token` / `⎿ Aborted (max turns exceeded)` |
78
+ | **Running** | `⠹ 3≤30 · 3 tool uses · 12.4k token` / `⎿ searching, reading 3 files…` |
79
+ | **Completed** | `✓ ⟳8 · 5 tool uses · 33.8k token · 12.3s` / `⎿ Done` |
80
+ | **Wrapped up** | `✓ 50≤50 · 50 tool uses · 89.1k token · 45.2s` / `⎿ Wrapped up (turn limit)` |
81
+ | **Stopped** | `■ 3 · 3 tool uses · 12.4k token` / `⎿ Stopped` |
82
+ | **Error** | `✗ 3 · 3 tool uses · 12.4k token` / `⎿ Error: timeout` |
83
+ | **Aborted** | `✗ 55≤50 · 55 tool uses · 102.3k token` / `⎿ Aborted (max turns exceeded)` |
84
84
 
85
85
  Completed results can be expanded (ctrl+o in pi) to show the full agent output inline.
86
86
 
@@ -88,7 +88,7 @@ Background agent completion notifications render as styled boxes:
88
88
 
89
89
  ```
90
90
  ✓ Find auth files completed
91
- 3 tool uses · 12.4k token · 4.1s
91
+ 3 · 3 tool uses · 12.4k token · 4.1s
92
92
  ⎿ Found 5 files related to authentication...
93
93
  transcript: .pi/output/agent-abc123.jsonl
94
94
  ```
@@ -162,7 +162,7 @@ All fields are optional — sensible defaults for everything.
162
162
  | `isolation` | — | Set to `worktree` to run in an isolated git worktree |
163
163
  | `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
164
164
  | `thinking` | inherit | off, minimal, low, medium, high, xhigh |
165
- | `max_turns` | 50 | Max agentic turns before graceful shutdown |
165
+ | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
166
166
  | `prompt_mode` | `replace` | `replace`: body is the full system prompt. `append`: body appended to parent's prompt (agent acts as a "parent twin" with optional extra instructions) |
167
167
  | `inherit_context` | `false` | Fork parent conversation into agent |
168
168
  | `run_in_background` | `false` | Run in background by default |
@@ -185,7 +185,7 @@ Launch a sub-agent.
185
185
  | `subagent_type` | string | yes | Agent type (built-in or custom) |
186
186
  | `model` | string | no | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
187
187
  | `thinking` | string | no | Thinking level: off, minimal, low, medium, high, xhigh |
188
- | `max_turns` | number | no | Max agentic turns (default: 50) |
188
+ | `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) |
189
189
  | `run_in_background` | boolean | no | Run without blocking |
190
190
  | `resume` | string | no | Agent ID to resume a previous session |
191
191
  | `isolated` | boolean | no | No extension/MCP tools |
package/biome.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
3
+ "linter": {
4
+ "enabled": true,
5
+ "rules": {
6
+ "recommended": true,
7
+ "style": {
8
+ "recommended": false
9
+ },
10
+ "suspicious": {
11
+ "noExplicitAny": "off",
12
+ "noControlCharactersInRegex": "off",
13
+ "noEmptyInterface": "off"
14
+ }
15
+ }
16
+ },
17
+ "formatter": {
18
+ "enabled": false
19
+ },
20
+ "files": {
21
+ "includes": [
22
+ "src/**/*.ts",
23
+ "test/**/*.ts"
24
+ ]
25
+ }
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -21,19 +21,22 @@
21
21
  "autonomous"
22
22
  ],
23
23
  "dependencies": {
24
- "@mariozechner/pi-ai": "^0.57.1",
25
- "@mariozechner/pi-coding-agent": "^0.57.1",
26
- "@mariozechner/pi-tui": "^0.57.1",
24
+ "@mariozechner/pi-ai": "^0.60.0",
25
+ "@mariozechner/pi-coding-agent": "^0.60.0",
26
+ "@mariozechner/pi-tui": "^0.60.0",
27
27
  "@sinclair/typebox": "latest"
28
28
  },
29
29
  "scripts": {
30
30
  "test": "vitest run",
31
31
  "test:watch": "vitest",
32
- "typecheck": "tsc --noEmit"
32
+ "typecheck": "tsc --noEmit",
33
+ "lint": "biome check src/ test/",
34
+ "lint:fix": "biome check --fix src/ test/"
33
35
  },
34
36
  "devDependencies": {
35
37
  "@types/node": "^20.0.0",
36
38
  "typescript": "^5.0.0",
39
+ "@biomejs/biome": "^2.3.5",
37
40
  "vitest": "^4.0.18"
38
41
  },
39
42
  "pi": {
@@ -7,12 +7,11 @@
7
7
  */
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
- import type { ExtensionContext, ExtensionAPI } from "@mariozechner/pi-coding-agent";
11
10
  import type { Model } from "@mariozechner/pi-ai";
12
- import type { AgentSession } from "@mariozechner/pi-coding-agent";
13
- import { runAgent, resumeAgent, type ToolActivity } from "./agent-runner.js";
14
- import type { SubagentType, AgentRecord, ThinkingLevel, IsolationMode } from "./types.js";
15
- import { createWorktree, cleanupWorktree, pruneWorktrees, type WorktreeInfo } from "./worktree.js";
11
+ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
+ import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
13
+ import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
14
+ import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
16
15
 
17
16
  export type OnAgentComplete = (record: AgentRecord) => void;
18
17
  export type OnAgentStart = (record: AgentRecord) => void;
@@ -44,6 +43,8 @@ interface SpawnOptions {
44
43
  onTextDelta?: (delta: string, fullText: string) => void;
45
44
  /** Called when the agent session is created (for accessing session stats). */
46
45
  onSessionCreated?: (session: AgentSession) => void;
46
+ /** Called at the end of each agentic turn with the cumulative count. */
47
+ onTurnEnd?: (turnCount: number) => void;
47
48
  }
48
49
 
49
50
  export class AgentManager {
@@ -149,6 +150,7 @@ export class AgentManager {
149
150
  if (activity.type === "end") record.toolUses++;
150
151
  options.onToolActivity?.(activity);
151
152
  },
153
+ onTurnEnd: options.onTurnEnd,
152
154
  onTextDelta: options.onTextDelta,
153
155
  onSessionCreated: (session) => {
154
156
  record.session = session;
@@ -2,35 +2,35 @@
2
2
  * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
3
  */
4
4
 
5
+ import type { Model } from "@mariozechner/pi-ai";
6
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
5
7
  import {
8
+ type AgentSession,
9
+ type AgentSessionEvent,
6
10
  createAgentSession,
7
11
  DefaultResourceLoader,
12
+ type ExtensionAPI,
8
13
  SessionManager,
9
14
  SettingsManager,
10
- type AgentSession,
11
- type AgentSessionEvent,
12
- type ExtensionAPI,
13
15
  } from "@mariozechner/pi-coding-agent";
14
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
15
- import type { Model } from "@mariozechner/pi-ai";
16
- import { getToolsForType, getConfig, getAgentConfig, getMemoryTools, getReadOnlyMemoryTools } from "./agent-types.js";
17
- import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
16
+ import { getAgentConfig, getConfig, getMemoryTools, getReadOnlyMemoryTools, getToolsForType } from "./agent-types.js";
18
17
  import { buildParentContext, extractText } from "./context.js";
19
18
  import { detectEnv } from "./env.js";
20
19
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
20
+ import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
21
21
  import { preloadSkills } from "./skill-loader.js";
22
22
  import type { SubagentType, ThinkingLevel } from "./types.js";
23
23
 
24
24
  /** Names of tools registered by this extension that subagents must NOT inherit. */
25
25
  const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
26
26
 
27
- /** Default max turns to prevent subagents from looping indefinitely. */
28
- let defaultMaxTurns = 50;
27
+ /** Default max turns. undefined = unlimited (no turn limit). */
28
+ let defaultMaxTurns: number | undefined;
29
29
 
30
- /** Get the default max turns value. */
31
- export function getDefaultMaxTurns(): number { return defaultMaxTurns; }
32
- /** Set the default max turns value (minimum 1). */
33
- export function setDefaultMaxTurns(n: number): void { defaultMaxTurns = Math.max(1, n); }
30
+ /** Get the default max turns value. undefined = unlimited. */
31
+ export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
32
+ /** Set the default max turns value. undefined = unlimited, otherwise minimum 1. */
33
+ export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = n != null ? Math.max(1, n) : undefined; }
34
34
 
35
35
  /** Additional turns allowed after the soft limit steer message. */
36
36
  let graceTurns = 5;
@@ -93,6 +93,8 @@ export interface RunOptions {
93
93
  /** Called on streaming text deltas from the assistant response. */
94
94
  onTextDelta?: (delta: string, fullText: string) => void;
95
95
  onSessionCreated?: (session: AgentSession) => void;
96
+ /** Called at the end of each agentic turn with the cumulative count. */
97
+ onTurnEnd?: (turnCount: number) => void;
96
98
  }
97
99
 
98
100
  export interface RunResult {
@@ -285,12 +287,15 @@ export async function runAgent(
285
287
  const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
286
288
  if (event.type === "turn_end") {
287
289
  turnCount++;
288
- if (!softLimitReached && turnCount >= maxTurns) {
289
- softLimitReached = true;
290
- session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
291
- } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
292
- aborted = true;
293
- session.abort();
290
+ options.onTurnEnd?.(turnCount);
291
+ if (maxTurns != null) {
292
+ if (!softLimitReached && turnCount >= maxTurns) {
293
+ softLimitReached = true;
294
+ session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
295
+ } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
296
+ aborted = true;
297
+ session.abort();
298
+ }
294
299
  }
295
300
  }
296
301
  if (event.type === "message_start") {
@@ -5,18 +5,18 @@
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
7
 
8
+ import type { AgentTool } from "@mariozechner/pi-agent-core";
8
9
  import {
9
- createReadTool,
10
10
  createBashTool,
11
11
  createEditTool,
12
- createWriteTool,
13
- createGrepTool,
14
12
  createFindTool,
13
+ createGrepTool,
15
14
  createLsTool,
15
+ createReadTool,
16
+ createWriteTool,
16
17
  } from "@mariozechner/pi-coding-agent";
17
- import type { AgentTool } from "@mariozechner/pi-agent-core";
18
- import type { AgentConfig } from "./types.js";
19
18
  import { DEFAULT_AGENTS } from "./default-agents.js";
19
+ import type { AgentConfig } from "./types.js";
20
20
 
21
21
  type ToolFactory = (cwd: string) => AgentTool<any>;
22
22
 
@@ -2,12 +2,12 @@
2
2
  * custom-agents.ts — Load user-defined agents from project (.pi/agents/) and global (~/.pi/agent/agents/) locations.
3
3
  */
4
4
 
5
- import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
6
- import { readFileSync, readdirSync, existsSync } from "node:fs";
7
- import { join, basename } from "node:path";
5
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
8
6
  import { homedir } from "node:os";
9
- import type { AgentConfig, ThinkingLevel, MemoryScope, IsolationMode } from "./types.js";
7
+ import { basename, join } from "node:path";
8
+ import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
10
9
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
10
+ import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
11
11
 
12
12
  /**
13
13
  * Scan for custom agent .md files from multiple locations.
package/src/index.ts CHANGED
@@ -10,32 +10,33 @@
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
12
 
13
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
14
- import { registerRpcHandlers } from "./cross-extension-rpc.js";
15
- import { existsSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
16
- import { join } from "node:path";
13
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
17
14
  import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
18
17
  import { Text } from "@mariozechner/pi-tui";
19
18
  import { Type } from "@sinclair/typebox";
20
19
  import { AgentManager } from "./agent-manager.js";
21
- import { steerAgent, getAgentConversation, getDefaultMaxTurns, setDefaultMaxTurns, getGraceTurns, setGraceTurns } from "./agent-runner.js";
22
- import { DEFAULT_AGENT_NAMES, type SubagentType, type ThinkingLevel, type AgentConfig, type JoinMode, type AgentRecord, type NotificationDetails } from "./types.js";
23
- import { GroupJoinManager } from "./group-join.js";
24
- import { getAvailableTypes, getAllTypes, getDefaultAgentNames, getUserAgentNames, getAgentConfig, resolveType, registerAgents, BUILTIN_TOOL_NAMES } from "./agent-types.js";
20
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
21
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
22
+ import { registerRpcHandlers } from "./cross-extension-rpc.js";
25
23
  import { loadCustomAgents } from "./custom-agents.js";
26
- import { resolveModel, type ModelRegistry } from "./model-resolver.js";
27
- import { createOutputFilePath, writeInitialEntry, streamToOutputFile } from "./output-file.js";
24
+ import { GroupJoinManager } from "./group-join.js";
25
+ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
26
+ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
27
+ import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ThinkingLevel } from "./types.js";
28
28
  import {
29
+ type AgentActivity,
30
+ type AgentDetails,
29
31
  AgentWidget,
30
- SPINNER,
31
- formatTokens,
32
- formatMs,
32
+ describeActivity,
33
33
  formatDuration,
34
+ formatMs,
35
+ formatTokens,
36
+ formatTurns,
34
37
  getDisplayName,
35
38
  getPromptModeLabel,
36
- describeActivity,
37
- type AgentDetails,
38
- type AgentActivity,
39
+ SPINNER,
39
40
  type UICtx,
40
41
  } from "./ui/agent-widget.js";
41
42
 
@@ -56,8 +57,8 @@ function safeFormatTokens(session: { getSessionStats(): { tokens: { total: numbe
56
57
  * Create an AgentActivity state and spawn callbacks for tracking tool usage.
57
58
  * Used by both foreground and background paths to avoid duplication.
58
59
  */
59
- function createActivityTracker(onStreamUpdate?: () => void) {
60
- const state: AgentActivity = { activeTools: new Map(), toolUses: 0, tokens: "", responseText: "", session: undefined };
60
+ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
61
+ const state: AgentActivity = { activeTools: new Map(), toolUses: 0, turnCount: 1, maxTurns, tokens: "", responseText: "", session: undefined };
61
62
 
62
63
  const callbacks = {
63
64
  onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
@@ -76,6 +77,10 @@ function createActivityTracker(onStreamUpdate?: () => void) {
76
77
  state.responseText = fullText;
77
78
  onStreamUpdate?.();
78
79
  },
80
+ onTurnEnd: (turnCount: number) => {
81
+ state.turnCount = turnCount;
82
+ onStreamUpdate?.();
83
+ },
79
84
  onSessionCreated: (session: any) => {
80
85
  state.session = session;
81
86
  },
@@ -145,12 +150,15 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri
145
150
  function buildDetails(
146
151
  base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
147
152
  record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any },
153
+ activity?: AgentActivity,
148
154
  overrides?: Partial<AgentDetails>,
149
155
  ): AgentDetails {
150
156
  return {
151
157
  ...base,
152
158
  toolUses: record.toolUses,
153
159
  tokens: safeFormatTokens(record.session),
160
+ turnCount: activity?.turnCount,
161
+ maxTurns: activity?.maxTurns,
154
162
  durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
155
163
  status: record.status as AgentDetails["status"],
156
164
  agentId: record.id,
@@ -160,7 +168,7 @@ function buildDetails(
160
168
  }
161
169
 
162
170
  /** Build notification details for the custom message renderer. */
163
- function buildNotificationDetails(record: AgentRecord, resultMaxLen: number): NotificationDetails {
171
+ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
164
172
  let totalTokens = 0;
165
173
  try {
166
174
  if (record.session) totalTokens = record.session.getSessionStats().tokens?.total ?? 0;
@@ -171,6 +179,8 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number): No
171
179
  description: record.description,
172
180
  status: record.status,
173
181
  toolUses: record.toolUses,
182
+ turnCount: activity?.turnCount ?? 0,
183
+ maxTurns: activity?.maxTurns,
174
184
  totalTokens,
175
185
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
176
186
  outputFile: record.outputFile,
@@ -203,6 +213,7 @@ export default function (pi: ExtensionAPI) {
203
213
 
204
214
  // Line 2: stats
205
215
  const parts: string[] = [];
216
+ if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
206
217
  if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
207
218
  if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
208
219
  if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
@@ -277,7 +288,7 @@ export default function (pi: ExtensionAPI) {
277
288
  customType: "subagent-notification",
278
289
  content: notification + footer,
279
290
  display: true,
280
- details: buildNotificationDetails(record, 500),
291
+ details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
281
292
  }, { deliverAs: "followUp" });
282
293
  }
283
294
 
@@ -305,9 +316,9 @@ export default function (pi: ExtensionAPI) {
305
316
  : `${unconsumed.length} agent(s) finished`;
306
317
 
307
318
  const [first, ...rest] = unconsumed;
308
- const details = buildNotificationDetails(first, 300);
319
+ const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
309
320
  if (rest.length > 0) {
310
- details.others = rest.map(r => buildNotificationDetails(r, 300));
321
+ details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
311
322
  }
312
323
 
313
324
  pi.sendMessage<NotificationDetails>({
@@ -585,7 +596,7 @@ Guidelines:
585
596
  ),
586
597
  max_turns: Type.Optional(
587
598
  Type.Number({
588
- description: "Maximum number of agentic turns before stopping.",
599
+ description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
589
600
  minimum: 1,
590
601
  }),
591
602
  ),
@@ -637,11 +648,14 @@ Guidelines:
637
648
  return new Text(text, 0, 0);
638
649
  }
639
650
 
640
- // Helper: build "haiku · thinking: high · 3 tool uses · 33.8k tokens" stats string
651
+ // Helper: build "haiku · thinking: high · ⟳5≤30 · 3 tool uses · 33.8k tokens" stats string
641
652
  const stats = (d: AgentDetails) => {
642
653
  const parts: string[] = [];
643
654
  if (d.modelName) parts.push(d.modelName);
644
655
  if (d.tags) parts.push(...d.tags);
656
+ if (d.turnCount != null && d.turnCount > 0) {
657
+ parts.push(formatTurns(d.turnCount, d.maxTurns));
658
+ }
645
659
  if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
646
660
  if (d.tokens) parts.push(d.tokens);
647
661
  return parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
@@ -762,6 +776,7 @@ Guidelines:
762
776
  if (thinking) agentTags.push(`thinking: ${thinking}`);
763
777
  if (isolated) agentTags.push("isolated");
764
778
  if (isolation === "worktree") agentTags.push("worktree");
779
+ const effectiveMaxTurns = params.max_turns ?? customConfig?.maxTurns ?? getDefaultMaxTurns();
765
780
  // Shared base fields for all AgentDetails in this call
766
781
  const detailBase = {
767
782
  displayName,
@@ -792,7 +807,7 @@ Guidelines:
792
807
 
793
808
  // Background execution
794
809
  if (runInBackground) {
795
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker();
810
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
796
811
 
797
812
  // Wrap onSessionCreated to wire output file streaming.
798
813
  // The callback lazily reads record.outputFile (set right after spawn)
@@ -878,6 +893,8 @@ Guidelines:
878
893
  ...detailBase,
879
894
  toolUses: fgState.toolUses,
880
895
  tokens: fgState.tokens,
896
+ turnCount: fgState.turnCount,
897
+ maxTurns: fgState.maxTurns,
881
898
  durationMs: Date.now() - startedAt,
882
899
  status: "running",
883
900
  activity: describeActivity(fgState.activeTools, fgState.responseText),
@@ -889,7 +906,7 @@ Guidelines:
889
906
  });
890
907
  };
891
908
 
892
- const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(streamUpdate);
909
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
893
910
 
894
911
  // Wire session creation to register in widget
895
912
  const origOnSession = fgCallbacks.onSessionCreated;
@@ -935,7 +952,7 @@ Guidelines:
935
952
  // Get final token count
936
953
  const tokenText = safeFormatTokens(fgState.session);
937
954
 
938
- const details = buildDetails(detailBase, record, { tokens: tokenText });
955
+ const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
939
956
 
940
957
  const fallbackNote = fellBack
941
958
  ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
@@ -1056,7 +1073,8 @@ Guidelines:
1056
1073
  }
1057
1074
  if (!record.session) {
1058
1075
  // Session not ready yet — queue the steer for delivery once initialized
1059
- (record.pendingSteers ??= []).push(params.message);
1076
+ if (!record.pendingSteers) record.pendingSteers = [];
1077
+ record.pendingSteers.push(params.message);
1060
1078
  pi.events.emit("subagents:steered", { id: record.id, message: params.message });
1061
1079
  return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
1062
1080
  }
@@ -1461,7 +1479,7 @@ description: <one-line description shown in UI>
1461
1479
  tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
1462
1480
  model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
1463
1481
  thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
1464
- max_turns: <optional max agentic turns, default 50. Omit for default>
1482
+ max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
1465
1483
  prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
1466
1484
  extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
1467
1485
  skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
@@ -1597,7 +1615,7 @@ ${systemPrompt}
1597
1615
  async function showSettings(ctx: ExtensionCommandContext) {
1598
1616
  const choice = await ctx.ui.select("Settings", [
1599
1617
  `Max concurrency (current: ${manager.getMaxConcurrent()})`,
1600
- `Default max turns (current: ${getDefaultMaxTurns()})`,
1618
+ `Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
1601
1619
  `Grace turns (current: ${getGraceTurns()})`,
1602
1620
  `Join mode (current: ${getDefaultJoinMode()})`,
1603
1621
  ]);
@@ -1615,14 +1633,17 @@ ${systemPrompt}
1615
1633
  }
1616
1634
  }
1617
1635
  } else if (choice.startsWith("Default max turns")) {
1618
- const val = await ctx.ui.input("Default max turns before wrap-up", String(getDefaultMaxTurns()));
1636
+ const val = await ctx.ui.input("Default max turns before wrap-up (0 = unlimited)", String(getDefaultMaxTurns() ?? 0));
1619
1637
  if (val) {
1620
1638
  const n = parseInt(val, 10);
1621
- if (n >= 1) {
1639
+ if (n === 0) {
1640
+ setDefaultMaxTurns(undefined);
1641
+ ctx.ui.notify("Default max turns set to unlimited", "info");
1642
+ } else if (n >= 1) {
1622
1643
  setDefaultMaxTurns(n);
1623
1644
  ctx.ui.notify(`Default max turns set to ${n}`, "info");
1624
1645
  } else {
1625
- ctx.ui.notify("Must be a positive integer.", "warning");
1646
+ ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
1626
1647
  }
1627
1648
  }
1628
1649
  } else if (choice.startsWith("Grace turns")) {
package/src/memory.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  * - "local" → .pi/agent-memory-local/{agent-name}/
8
8
  */
9
9
 
10
- import { existsSync, readFileSync, mkdirSync, lstatSync } from "node:fs";
11
- import { join, resolve } from "node:path";
10
+ import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
12
11
  import { homedir } from "node:os";
12
+ import { join, } from "node:path";
13
13
  import type { MemoryScope } from "./types.js";
14
14
 
15
15
  /** Maximum lines to read from MEMORY.md */
@@ -5,9 +5,9 @@
5
5
  * matching Claude Code's task output file format.
6
6
  */
7
7
 
8
+ import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
8
9
  import { tmpdir } from "node:os";
9
10
  import { join } from "node:path";
10
- import { mkdirSync, chmodSync, appendFileSync, writeFileSync } from "node:fs";
11
11
  import type { AgentSession, AgentSessionEvent } from "@mariozechner/pi-coding-agent";
12
12
 
13
13
  /** Create the output file path, ensuring the directory exists.
@@ -5,8 +5,8 @@
5
5
  * and returns their content for injection into the agent's system prompt.
6
6
  */
7
7
 
8
- import { join } from "node:path";
9
8
  import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
10
  import { isUnsafeName, safeReadFile } from "./memory.js";
11
11
 
12
12
  export interface PreloadedSkill {
package/src/types.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * types.ts — Type definitions for the subagent system.
3
3
  */
4
4
 
5
- import type { AgentSession } from "@mariozechner/pi-coding-agent";
6
5
  import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
6
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
7
7
 
8
8
  export type { ThinkingLevel };
9
9
 
@@ -93,6 +93,8 @@ export interface NotificationDetails {
93
93
  description: string;
94
94
  status: string;
95
95
  toolUses: number;
96
+ turnCount: number;
97
+ maxTurns?: number;
96
98
  totalTokens: number;
97
99
  durationMs: number;
98
100
  outputFile?: string;
@@ -7,8 +7,8 @@
7
7
 
8
8
  import { truncateToWidth } from "@mariozechner/pi-tui";
9
9
  import type { AgentManager } from "../agent-manager.js";
10
- import type { SubagentType } from "../types.js";
11
10
  import { getConfig } from "../agent-types.js";
11
+ import type { SubagentType } from "../types.js";
12
12
 
13
13
  // ---- Constants ----
14
14
 
@@ -55,6 +55,10 @@ export interface AgentActivity {
55
55
  tokens: string;
56
56
  responseText: string;
57
57
  session?: { getSessionStats(): { tokens: { total: number } } };
58
+ /** Current turn count. */
59
+ turnCount: number;
60
+ /** Effective max turns for this agent (undefined = unlimited). */
61
+ maxTurns?: number;
58
62
  }
59
63
 
60
64
  /** Metadata attached to Agent tool results for custom rendering. */
@@ -74,6 +78,10 @@ export interface AgentDetails {
74
78
  modelName?: string;
75
79
  /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
76
80
  tags?: string[];
81
+ /** Current turn count. */
82
+ turnCount?: number;
83
+ /** Effective max turns (undefined = unlimited). */
84
+ maxTurns?: number;
77
85
  agentId?: string;
78
86
  error?: string;
79
87
  }
@@ -87,6 +95,11 @@ export function formatTokens(count: number): string {
87
95
  return `${count} token`;
88
96
  }
89
97
 
98
+ /** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
99
+ export function formatTurns(turnCount: number, maxTurns?: number | null): string {
100
+ return maxTurns != null ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
101
+ }
102
+
90
103
  /** Format milliseconds as human-readable duration. */
91
104
  export function formatMs(ms: number): string {
92
105
  return `${(ms / 1000).toFixed(1)}s`;
@@ -214,7 +227,7 @@ export class AgentWidget {
214
227
  }
215
228
 
216
229
  /** Render a finished agent line. */
217
- private renderFinishedLine(a: { type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
230
+ private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
218
231
  const name = getDisplayName(a.type);
219
232
  const modeLabel = getPromptModeLabel(a.type);
220
233
  const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
@@ -241,6 +254,8 @@ export class AgentWidget {
241
254
  }
242
255
 
243
256
  const parts: string[] = [];
257
+ const activity = this.agentActivity.get(a.id);
258
+ if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
244
259
  if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
245
260
  parts.push(duration);
246
261
 
@@ -296,6 +311,7 @@ export class AgentWidget {
296
311
  }
297
312
 
298
313
  const parts: string[] = [];
314
+ if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
299
315
  if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
300
316
  if (tokenText) parts.push(tokenText);
301
317
  parts.push(elapsed);
@@ -5,12 +5,12 @@
5
5
  * Subscribes to session events for real-time streaming updates.
6
6
  */
7
7
 
8
- import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type TUI } from "@mariozechner/pi-tui";
9
8
  import type { AgentSession } from "@mariozechner/pi-coding-agent";
10
- import type { Theme } from "./agent-widget.js";
11
- import { formatTokens, formatDuration, getDisplayName, getPromptModeLabel, describeActivity, type AgentActivity } from "./agent-widget.js";
12
- import type { AgentRecord } from "../types.js";
9
+ import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
13
10
  import { extractText } from "../context.js";
11
+ import type { AgentRecord } from "../types.js";
12
+ import type { Theme } from "./agent-widget.js";
13
+ import { type AgentActivity, describeActivity, formatDuration, formatTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
14
14
 
15
15
  /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
16
16
  const CHROME_LINES = 6;
@@ -238,6 +238,6 @@ export class ConversationViewer implements Component {
238
238
  lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
239
239
  }
240
240
 
241
- return lines;
241
+ return lines.map(l => truncateToWidth(l, width));
242
242
  }
243
243
  }
package/src/worktree.ts CHANGED
@@ -7,10 +7,10 @@
7
7
  */
8
8
 
9
9
  import { execFileSync } from "node:child_process";
10
+ import { randomUUID } from "node:crypto";
10
11
  import { existsSync } from "node:fs";
11
- import { join } from "node:path";
12
12
  import { tmpdir } from "node:os";
13
- import { randomUUID } from "node:crypto";
13
+ import { join } from "node:path";
14
14
 
15
15
  export interface WorktreeInfo {
16
16
  /** Absolute path to the worktree directory. */