@tintinweb/pi-subagents 0.7.0 → 0.7.2
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/CHANGELOG.md +37 -0
- package/README.md +24 -6
- package/dist/agent-manager.d.ts +3 -1
- package/dist/agent-manager.js +40 -19
- package/dist/index.js +60 -40
- package/dist/skill-loader.d.ts +16 -11
- package/dist/skill-loader.js +79 -53
- package/dist/types.d.ts +12 -0
- package/dist/ui/agent-widget.d.ts +6 -1
- package/dist/ui/agent-widget.js +19 -0
- package/dist/ui/conversation-viewer.d.ts +4 -0
- package/dist/ui/conversation-viewer.js +25 -7
- package/package.json +1 -1
- package/src/agent-manager.ts +41 -21
- package/src/index.ts +56 -36
- package/src/skill-loader.ts +77 -54
- package/src/types.ts +13 -0
- package/src/ui/agent-widget.ts +16 -1
- package/src/ui/conversation-viewer.ts +25 -7
|
@@ -9,6 +9,8 @@ import { type Component, type TUI } from "@mariozechner/pi-tui";
|
|
|
9
9
|
import type { AgentRecord } from "../types.js";
|
|
10
10
|
import type { Theme } from "./agent-widget.js";
|
|
11
11
|
import { type AgentActivity } from "./agent-widget.js";
|
|
12
|
+
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
13
|
+
export declare const VIEWPORT_HEIGHT_PCT = 70;
|
|
12
14
|
export declare class ConversationViewer implements Component {
|
|
13
15
|
private tui;
|
|
14
16
|
private session;
|
|
@@ -27,5 +29,7 @@ export declare class ConversationViewer implements Component {
|
|
|
27
29
|
invalidate(): void;
|
|
28
30
|
dispose(): void;
|
|
29
31
|
private viewportHeight;
|
|
32
|
+
private chromeLines;
|
|
33
|
+
private invocationLine;
|
|
30
34
|
private buildContentLines;
|
|
31
35
|
}
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
8
8
|
import { extractText } from "../context.js";
|
|
9
9
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
10
|
-
import { describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
11
|
-
/**
|
|
12
|
-
const
|
|
10
|
+
import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
11
|
+
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
|
|
12
|
+
const CHROME_LINES_BASE = 6;
|
|
13
13
|
const MIN_VIEWPORT = 3;
|
|
14
|
+
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
15
|
+
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
14
16
|
export class ConversationViewer {
|
|
15
17
|
tui;
|
|
16
18
|
session;
|
|
@@ -53,11 +55,11 @@ export class ConversationViewer {
|
|
|
53
55
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
54
56
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
55
57
|
}
|
|
56
|
-
else if (matchesKey(data, "pageUp")) {
|
|
58
|
+
else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
57
59
|
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
58
60
|
this.autoScroll = false;
|
|
59
61
|
}
|
|
60
|
-
else if (matchesKey(data, "pageDown")) {
|
|
62
|
+
else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
61
63
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
62
64
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
63
65
|
}
|
|
@@ -108,6 +110,9 @@ export class ConversationViewer {
|
|
|
108
110
|
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
|
|
109
111
|
}
|
|
110
112
|
lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`));
|
|
113
|
+
const invocationLine = this.invocationLine();
|
|
114
|
+
if (invocationLine)
|
|
115
|
+
lines.push(row(invocationLine));
|
|
111
116
|
lines.push(hrMid);
|
|
112
117
|
// Content area — rebuild every render (live data, no cache needed)
|
|
113
118
|
const contentLines = this.buildContentLines(innerW);
|
|
@@ -127,7 +132,7 @@ export class ConversationViewer {
|
|
|
127
132
|
? "100%"
|
|
128
133
|
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
129
134
|
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
130
|
-
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
|
|
135
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
|
|
131
136
|
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
132
137
|
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
133
138
|
lines.push(hrBot);
|
|
@@ -143,7 +148,20 @@ export class ConversationViewer {
|
|
|
143
148
|
}
|
|
144
149
|
// ---- Private ----
|
|
145
150
|
viewportHeight() {
|
|
146
|
-
|
|
151
|
+
// Cap mirrors the overlay's maxHeight — otherwise the viewer would render
|
|
152
|
+
// more lines than the overlay shows and clip the footer.
|
|
153
|
+
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
|
|
154
|
+
return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
|
|
155
|
+
}
|
|
156
|
+
chromeLines() {
|
|
157
|
+
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
|
|
158
|
+
}
|
|
159
|
+
invocationLine() {
|
|
160
|
+
const { modelName, tags } = buildInvocationTags(this.record.invocation);
|
|
161
|
+
const parts = modelName ? [modelName, ...tags] : tags;
|
|
162
|
+
if (parts.length === 0)
|
|
163
|
+
return undefined;
|
|
164
|
+
return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
|
|
147
165
|
}
|
|
148
166
|
buildContentLines(width) {
|
|
149
167
|
if (width <= 0)
|
package/package.json
CHANGED
package/src/agent-manager.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto";
|
|
|
10
10
|
import type { Model } from "@mariozechner/pi-ai";
|
|
11
11
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
12
12
|
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
13
|
-
import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
13
|
+
import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
14
14
|
import { addUsage } from "./usage.js";
|
|
15
15
|
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
|
|
16
16
|
|
|
@@ -46,6 +46,8 @@ interface SpawnOptions {
|
|
|
46
46
|
bypassQueue?: boolean;
|
|
47
47
|
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
|
|
48
48
|
isolation?: IsolationMode;
|
|
49
|
+
/** Resolved invocation snapshot captured for UI display. */
|
|
50
|
+
invocation?: AgentInvocation;
|
|
49
51
|
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
50
52
|
signal?: AbortSignal;
|
|
51
53
|
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
@@ -87,6 +89,7 @@ export class AgentManager {
|
|
|
87
89
|
this.maxConcurrent = maxConcurrent;
|
|
88
90
|
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
89
91
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
92
|
+
this.cleanupInterval.unref();
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
/** Update the max concurrent background agents limit. */
|
|
@@ -123,6 +126,7 @@ export class AgentManager {
|
|
|
123
126
|
abortController,
|
|
124
127
|
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
125
128
|
compactionCount: 0,
|
|
129
|
+
invocation: options.invocation,
|
|
126
130
|
};
|
|
127
131
|
this.agents.set(id, record);
|
|
128
132
|
|
|
@@ -134,12 +138,35 @@ export class AgentManager {
|
|
|
134
138
|
return id;
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
|
|
141
|
+
// startAgent can throw (e.g. strict worktree-isolation failure) — clean
|
|
142
|
+
// up the record so callers don't see an orphan in `listAgents()`.
|
|
143
|
+
try {
|
|
144
|
+
this.startAgent(id, record, args);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
this.agents.delete(id);
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
138
149
|
return id;
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
142
153
|
private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
|
|
154
|
+
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
155
|
+
// fail loud if not possible (no silent fallback to main tree). Done
|
|
156
|
+
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
157
|
+
let worktreeCwd: string | undefined;
|
|
158
|
+
if (options.isolation === "worktree") {
|
|
159
|
+
const wt = createWorktree(ctx.cwd, id);
|
|
160
|
+
if (!wt) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
163
|
+
'Initialize git and commit at least once, or omit `isolation`.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
record.worktree = wt;
|
|
167
|
+
worktreeCwd = wt.path;
|
|
168
|
+
}
|
|
169
|
+
|
|
143
170
|
record.status = "running";
|
|
144
171
|
record.startedAt = Date.now();
|
|
145
172
|
if (options.isBackground) this.runningBackground++;
|
|
@@ -154,23 +181,7 @@ export class AgentManager {
|
|
|
154
181
|
}
|
|
155
182
|
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
156
183
|
|
|
157
|
-
|
|
158
|
-
let worktreeCwd: string | undefined;
|
|
159
|
-
let worktreeWarning = "";
|
|
160
|
-
if (options.isolation === "worktree") {
|
|
161
|
-
const wt = createWorktree(ctx.cwd, id);
|
|
162
|
-
if (wt) {
|
|
163
|
-
record.worktree = wt;
|
|
164
|
-
worktreeCwd = wt.path;
|
|
165
|
-
} else {
|
|
166
|
-
worktreeWarning = "\n\n[WARNING: Worktree isolation was requested but failed (not a git repo, or no commits yet). Running in the main working directory instead.]";
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Prepend worktree warning to prompt if isolation failed
|
|
171
|
-
const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
|
|
172
|
-
|
|
173
|
-
const promise = runAgent(ctx, type, effectivePrompt, {
|
|
184
|
+
const promise = runAgent(ctx, type, prompt, {
|
|
174
185
|
pi,
|
|
175
186
|
model: options.model,
|
|
176
187
|
maxTurns: options.maxTurns,
|
|
@@ -235,7 +246,7 @@ export class AgentManager {
|
|
|
235
246
|
|
|
236
247
|
if (options.isBackground) {
|
|
237
248
|
this.runningBackground--;
|
|
238
|
-
this.onComplete?.(record);
|
|
249
|
+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
|
|
239
250
|
this.drainQueue();
|
|
240
251
|
}
|
|
241
252
|
return responseText;
|
|
@@ -281,7 +292,16 @@ export class AgentManager {
|
|
|
281
292
|
const next = this.queue.shift()!;
|
|
282
293
|
const record = this.agents.get(next.id);
|
|
283
294
|
if (!record || record.status !== "queued") continue;
|
|
284
|
-
|
|
295
|
+
try {
|
|
296
|
+
this.startAgent(next.id, record, next.args);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
// Late failure (e.g. strict worktree-isolation) — surface on the record
|
|
299
|
+
// so the user/agent can see it via /agents, then keep draining.
|
|
300
|
+
record.status = "error";
|
|
301
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
302
|
+
record.completedAt = Date.now();
|
|
303
|
+
this.onComplete?.(record);
|
|
304
|
+
}
|
|
285
305
|
}
|
|
286
306
|
}
|
|
287
307
|
|
package/src/index.ts
CHANGED
|
@@ -27,11 +27,12 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
|
|
|
27
27
|
import { SubagentScheduler } from "./schedule.js";
|
|
28
28
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
29
29
|
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
|
|
30
|
-
import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
|
|
30
|
+
import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
|
|
31
31
|
import {
|
|
32
32
|
type AgentActivity,
|
|
33
33
|
type AgentDetails,
|
|
34
34
|
AgentWidget,
|
|
35
|
+
buildInvocationTags,
|
|
35
36
|
describeActivity,
|
|
36
37
|
formatDuration,
|
|
37
38
|
formatMs,
|
|
@@ -275,7 +276,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
275
276
|
cancelNudge(key);
|
|
276
277
|
pendingNudges.set(key, setTimeout(() => {
|
|
277
278
|
pendingNudges.delete(key);
|
|
278
|
-
send();
|
|
279
|
+
try { send(); } catch { /* ignore stale completion side-effect errors */ }
|
|
279
280
|
}, delay));
|
|
280
281
|
}
|
|
281
282
|
|
|
@@ -847,25 +848,32 @@ Guidelines:
|
|
|
847
848
|
const isolated = resolvedConfig.isolated;
|
|
848
849
|
const isolation = resolvedConfig.isolation;
|
|
849
850
|
|
|
850
|
-
// Build display tags for non-default config
|
|
851
851
|
const parentModelId = ctx.model?.id;
|
|
852
852
|
const effectiveModelId = model?.id;
|
|
853
|
-
const
|
|
853
|
+
const modelName = effectiveModelId && effectiveModelId !== parentModelId
|
|
854
854
|
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
|
|
855
855
|
: undefined;
|
|
856
|
-
const agentTags: string[] = [];
|
|
857
|
-
const modeLabel = getPromptModeLabel(subagentType);
|
|
858
|
-
if (modeLabel) agentTags.push(modeLabel);
|
|
859
|
-
if (thinking) agentTags.push(`thinking: ${thinking}`);
|
|
860
|
-
if (isolated) agentTags.push("isolated");
|
|
861
|
-
if (isolation === "worktree") agentTags.push("worktree");
|
|
862
856
|
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
863
|
-
|
|
857
|
+
const agentInvocation: AgentInvocation = {
|
|
858
|
+
modelName,
|
|
859
|
+
thinking,
|
|
860
|
+
// Explicit value only — the default fallback would just add noise.
|
|
861
|
+
// Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
|
|
862
|
+
maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
|
|
863
|
+
isolated,
|
|
864
|
+
inheritContext,
|
|
865
|
+
runInBackground,
|
|
866
|
+
isolation,
|
|
867
|
+
};
|
|
868
|
+
// Tool-result render shows the mode label too; viewer's header already does.
|
|
869
|
+
const modeLabel = getPromptModeLabel(subagentType);
|
|
870
|
+
const { tags: invocationTags } = buildInvocationTags(agentInvocation);
|
|
871
|
+
const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
|
|
864
872
|
const detailBase = {
|
|
865
873
|
displayName,
|
|
866
874
|
description: params.description,
|
|
867
875
|
subagentType,
|
|
868
|
-
modelName
|
|
876
|
+
modelName,
|
|
869
877
|
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
870
878
|
};
|
|
871
879
|
|
|
@@ -946,17 +954,22 @@ Guidelines:
|
|
|
946
954
|
}
|
|
947
955
|
};
|
|
948
956
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
957
|
+
try {
|
|
958
|
+
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
959
|
+
description: params.description,
|
|
960
|
+
model,
|
|
961
|
+
maxTurns: effectiveMaxTurns,
|
|
962
|
+
isolated,
|
|
963
|
+
inheritContext,
|
|
964
|
+
thinkingLevel: thinking,
|
|
965
|
+
isBackground: true,
|
|
966
|
+
isolation,
|
|
967
|
+
invocation: agentInvocation,
|
|
968
|
+
...bgCallbacks,
|
|
969
|
+
});
|
|
970
|
+
} catch (err) {
|
|
971
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
972
|
+
}
|
|
960
973
|
|
|
961
974
|
// Set output file + join mode synchronously after spawn, before the
|
|
962
975
|
// event loop yields — onSessionCreated is async so this is safe.
|
|
@@ -1054,17 +1067,24 @@ Guidelines:
|
|
|
1054
1067
|
|
|
1055
1068
|
streamUpdate();
|
|
1056
1069
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1070
|
+
let record: AgentRecord;
|
|
1071
|
+
try {
|
|
1072
|
+
record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
1073
|
+
description: params.description,
|
|
1074
|
+
model,
|
|
1075
|
+
maxTurns: effectiveMaxTurns,
|
|
1076
|
+
isolated,
|
|
1077
|
+
inheritContext,
|
|
1078
|
+
thinkingLevel: thinking,
|
|
1079
|
+
isolation,
|
|
1080
|
+
invocation: agentInvocation,
|
|
1081
|
+
signal,
|
|
1082
|
+
...fgCallbacks,
|
|
1083
|
+
});
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
clearInterval(spinnerInterval);
|
|
1086
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
1087
|
+
}
|
|
1068
1088
|
|
|
1069
1089
|
clearInterval(spinnerInterval);
|
|
1070
1090
|
|
|
@@ -1396,7 +1416,7 @@ Guidelines:
|
|
|
1396
1416
|
return;
|
|
1397
1417
|
}
|
|
1398
1418
|
|
|
1399
|
-
const { ConversationViewer } = await import("./ui/conversation-viewer.js");
|
|
1419
|
+
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
|
|
1400
1420
|
const session = record.session;
|
|
1401
1421
|
const activity = agentActivity.get(record.id);
|
|
1402
1422
|
|
|
@@ -1406,7 +1426,7 @@ Guidelines:
|
|
|
1406
1426
|
},
|
|
1407
1427
|
{
|
|
1408
1428
|
overlay: true,
|
|
1409
|
-
overlayOptions: { anchor: "center", width: "90%" },
|
|
1429
|
+
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
1410
1430
|
},
|
|
1411
1431
|
);
|
|
1412
1432
|
}
|
package/src/skill-loader.ts
CHANGED
|
@@ -1,79 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* skill-loader.ts — Preload
|
|
2
|
+
* skill-loader.ts — Preload named skills.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Roots, in precedence order:
|
|
5
|
+
* - <cwd>/.pi/skills (project, Pi's standard)
|
|
6
|
+
* - <cwd>/.agents/skills (project, cross-tool Agent Skills spec — https://agentskills.io)
|
|
7
|
+
* - getAgentDir()/skills (user, default ~/.pi/agent/skills — Pi's standard)
|
|
8
|
+
* - ~/.agents/skills (user, cross-tool Agent Skills spec)
|
|
9
|
+
* - ~/.pi/skills (legacy global, pre-Pi)
|
|
10
|
+
*
|
|
11
|
+
* Layout per root:
|
|
12
|
+
* - <root>/<name>.md (flat file at the top level)
|
|
13
|
+
* - <root>/.../<name>/SKILL.md (directory skill, may be nested — Pi's standard)
|
|
14
|
+
*
|
|
15
|
+
* Recursion skips dotfile entries and node_modules. A directory that itself contains
|
|
16
|
+
* SKILL.md is a skill — we don't descend into it (Pi: skills don't nest).
|
|
17
|
+
*
|
|
18
|
+
* Symlinks are rejected for security (deviation from Pi, which follows them).
|
|
6
19
|
*/
|
|
7
20
|
|
|
21
|
+
import type { Dirent } from "node:fs";
|
|
22
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
8
23
|
import { homedir } from "node:os";
|
|
9
24
|
import { join } from "node:path";
|
|
10
|
-
import {
|
|
25
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
26
|
+
import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
|
|
11
27
|
|
|
12
28
|
export interface PreloadedSkill {
|
|
13
29
|
name: string;
|
|
14
30
|
content: string;
|
|
15
31
|
}
|
|
16
32
|
|
|
17
|
-
/**
|
|
18
|
-
* Attempt to load named skills from project and global skill directories.
|
|
19
|
-
* Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
|
|
20
|
-
*
|
|
21
|
-
* @param skillNames List of skill names to preload.
|
|
22
|
-
* @param cwd Working directory for project-level skills.
|
|
23
|
-
* @returns Array of loaded skills (missing skills are skipped with a warning comment).
|
|
24
|
-
*/
|
|
25
33
|
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
|
|
26
|
-
|
|
34
|
+
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
|
|
35
|
+
}
|
|
27
36
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (isUnsafeName(name)) {
|
|
32
|
-
results.push({ name, content: `(Skill "${name}" skipped: name contains path traversal characters)` });
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
35
|
-
const content = findAndReadSkill(name, cwd);
|
|
36
|
-
if (content !== undefined) {
|
|
37
|
-
results.push({ name, content });
|
|
38
|
-
} else {
|
|
39
|
-
// Include a note about missing skills so the agent knows it was requested but not found
|
|
40
|
-
results.push({ name, content: `(Skill "${name}" not found in .pi/skills/ or ~/.pi/skills/)` });
|
|
41
|
-
}
|
|
37
|
+
function loadSkillContent(name: string, cwd: string): string {
|
|
38
|
+
if (isUnsafeName(name)) {
|
|
39
|
+
return `(Skill "${name}" skipped: name contains path traversal characters)`;
|
|
42
40
|
}
|
|
41
|
+
const roots = [
|
|
42
|
+
join(cwd, ".pi", "skills"), // project — Pi standard
|
|
43
|
+
join(cwd, ".agents", "skills"), // project — Agent Skills spec
|
|
44
|
+
join(getAgentDir(), "skills"), // user — Pi standard
|
|
45
|
+
join(homedir(), ".agents", "skills"), // user — Agent Skills spec
|
|
46
|
+
join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
|
|
47
|
+
];
|
|
48
|
+
for (const root of roots) {
|
|
49
|
+
const content = findInRoot(root, name);
|
|
50
|
+
if (content !== undefined) return content;
|
|
51
|
+
}
|
|
52
|
+
return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
|
|
53
|
+
}
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
function findInRoot(root: string, name: string): string | undefined {
|
|
56
|
+
if (isSymlink(root)) return undefined; // reject symlinked roots entirely
|
|
57
|
+
const flat = safeReadFile(join(root, `${name}.md`))?.trim();
|
|
58
|
+
if (flat !== undefined) return flat;
|
|
59
|
+
return findSkillDirectory(root, name);
|
|
45
60
|
}
|
|
46
61
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
function findAndReadSkill(name: string, cwd: string): string | undefined {
|
|
52
|
-
const projectDir = join(cwd, ".pi", "skills");
|
|
53
|
-
const globalDir = join(homedir(), ".pi", "skills");
|
|
62
|
+
/** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
|
|
63
|
+
function findSkillDirectory(root: string, name: string): string | undefined {
|
|
64
|
+
if (!existsSync(root)) return undefined;
|
|
65
|
+
const queue: string[] = [root];
|
|
54
66
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (content !== undefined) return content;
|
|
59
|
-
}
|
|
67
|
+
while (queue.length > 0) {
|
|
68
|
+
const current = queue.shift();
|
|
69
|
+
if (current === undefined) continue;
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
let entries: Dirent<string>[];
|
|
72
|
+
try {
|
|
73
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
63
77
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
* Tries extensions in order: .md, .txt, (no extension)
|
|
67
|
-
*/
|
|
68
|
-
function tryReadSkillFile(dir: string, name: string): string | undefined {
|
|
69
|
-
const extensions = [".md", ".txt", ""];
|
|
78
|
+
// Deterministic byte-order traversal — locale-independent.
|
|
79
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
70
80
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (!entry.isDirectory()) continue;
|
|
83
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
84
|
+
|
|
85
|
+
// Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
|
|
86
|
+
const path = join(current, entry.name);
|
|
87
|
+
const skillMd = join(path, "SKILL.md");
|
|
88
|
+
const isSkillDir = existsSync(skillMd);
|
|
77
89
|
|
|
90
|
+
if (isSkillDir) {
|
|
91
|
+
if (entry.name === name) {
|
|
92
|
+
const content = safeReadFile(skillMd)?.trim();
|
|
93
|
+
if (content !== undefined) return content;
|
|
94
|
+
}
|
|
95
|
+
continue; // Pi rule: skills don't nest — don't descend into a skill dir
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
queue.push(path);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
78
101
|
return undefined;
|
|
79
102
|
}
|
package/src/types.ts
CHANGED
|
@@ -94,6 +94,19 @@ export interface AgentRecord {
|
|
|
94
94
|
lifetimeUsage: LifetimeUsage;
|
|
95
95
|
/** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
|
|
96
96
|
compactionCount: number;
|
|
97
|
+
/** Resolved spawn params, captured for UI display. Fixed at spawn time. */
|
|
98
|
+
invocation?: AgentInvocation;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface AgentInvocation {
|
|
102
|
+
/** Short display name, e.g. "haiku" — only set when different from parent. */
|
|
103
|
+
modelName?: string;
|
|
104
|
+
thinking?: ThinkingLevel;
|
|
105
|
+
maxTurns?: number;
|
|
106
|
+
isolated?: boolean;
|
|
107
|
+
inheritContext?: boolean;
|
|
108
|
+
runInBackground?: boolean;
|
|
109
|
+
isolation?: IsolationMode;
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
/** Details attached to custom notification messages for visual rendering. */
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
9
9
|
import type { AgentManager } from "../agent-manager.js";
|
|
10
10
|
import { getConfig } from "../agent-types.js";
|
|
11
|
-
import type { SubagentType } from "../types.js";
|
|
11
|
+
import type { AgentInvocation, SubagentType } from "../types.js";
|
|
12
12
|
import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
|
|
13
13
|
|
|
14
14
|
// ---- Constants ----
|
|
@@ -153,6 +153,21 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
|
|
|
153
153
|
return config.promptMode === "append" ? "twin" : undefined;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** Mode label is not included — callers add it where they want it. */
|
|
157
|
+
export function buildInvocationTags(
|
|
158
|
+
invocation: AgentInvocation | undefined,
|
|
159
|
+
): { modelName?: string; tags: string[] } {
|
|
160
|
+
const tags: string[] = [];
|
|
161
|
+
if (!invocation) return { tags };
|
|
162
|
+
if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
|
|
163
|
+
if (invocation.isolated) tags.push("isolated");
|
|
164
|
+
if (invocation.isolation === "worktree") tags.push("worktree");
|
|
165
|
+
if (invocation.inheritContext) tags.push("inherit context");
|
|
166
|
+
if (invocation.runInBackground) tags.push("background");
|
|
167
|
+
if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
|
|
168
|
+
return { modelName: invocation.modelName, tags };
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
/** Truncate text to a single line, max `len` chars. */
|
|
157
172
|
function truncateLine(text: string, len = 60): string {
|
|
158
173
|
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
|
|
@@ -11,11 +11,13 @@ import { extractText } from "../context.js";
|
|
|
11
11
|
import type { AgentRecord } from "../types.js";
|
|
12
12
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
13
13
|
import type { Theme } from "./agent-widget.js";
|
|
14
|
-
import { type AgentActivity, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
14
|
+
import { type AgentActivity, buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
const
|
|
16
|
+
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
|
|
17
|
+
const CHROME_LINES_BASE = 6;
|
|
18
18
|
const MIN_VIEWPORT = 3;
|
|
19
|
+
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
20
|
+
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
19
21
|
|
|
20
22
|
export class ConversationViewer implements Component {
|
|
21
23
|
private scrollOffset = 0;
|
|
@@ -55,10 +57,10 @@ export class ConversationViewer implements Component {
|
|
|
55
57
|
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
56
58
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
57
59
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
58
|
-
} else if (matchesKey(data, "pageUp")) {
|
|
60
|
+
} else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
59
61
|
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
60
62
|
this.autoScroll = false;
|
|
61
|
-
} else if (matchesKey(data, "pageDown")) {
|
|
63
|
+
} else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
62
64
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
63
65
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
64
66
|
} else if (matchesKey(data, "home")) {
|
|
@@ -113,6 +115,8 @@ export class ConversationViewer implements Component {
|
|
|
113
115
|
lines.push(row(
|
|
114
116
|
`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
|
|
115
117
|
));
|
|
118
|
+
const invocationLine = this.invocationLine();
|
|
119
|
+
if (invocationLine) lines.push(row(invocationLine));
|
|
116
120
|
lines.push(hrMid);
|
|
117
121
|
|
|
118
122
|
// Content area — rebuild every render (live data, no cache needed)
|
|
@@ -137,7 +141,7 @@ export class ConversationViewer implements Component {
|
|
|
137
141
|
? "100%"
|
|
138
142
|
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
139
143
|
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
140
|
-
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
|
|
144
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
|
|
141
145
|
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
142
146
|
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
143
147
|
lines.push(hrBot);
|
|
@@ -158,7 +162,21 @@ export class ConversationViewer implements Component {
|
|
|
158
162
|
// ---- Private ----
|
|
159
163
|
|
|
160
164
|
private viewportHeight(): number {
|
|
161
|
-
|
|
165
|
+
// Cap mirrors the overlay's maxHeight — otherwise the viewer would render
|
|
166
|
+
// more lines than the overlay shows and clip the footer.
|
|
167
|
+
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
|
|
168
|
+
return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private chromeLines(): number {
|
|
172
|
+
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private invocationLine(): string | undefined {
|
|
176
|
+
const { modelName, tags } = buildInvocationTags(this.record.invocation);
|
|
177
|
+
const parts = modelName ? [modelName, ...tags] : tags;
|
|
178
|
+
if (parts.length === 0) return undefined;
|
|
179
|
+
return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
|
|
162
180
|
}
|
|
163
181
|
|
|
164
182
|
private buildContentLines(width: number): string[] {
|