@signalridge/pi-subagents 1.11.0 → 1.12.0
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 +40 -0
- package/README.md +76 -8
- package/package.json +8 -8
- package/src/agent-color.ts +1 -1
- package/src/agent-manager.ts +27 -2
- package/src/agent-runner.ts +142 -56
- package/src/agent-types.ts +6 -0
- package/src/custom-agents.ts +12 -4
- package/src/enabled-models.ts +12 -5
- package/src/extension-resource-names.ts +105 -0
- package/src/gate.ts +0 -0
- package/src/index.ts +177 -67
- package/src/model-scope.ts +4 -1
- package/src/nested-tools.ts +10 -4
- package/src/parent-questions.ts +453 -0
- package/src/project-trust.ts +146 -0
- package/src/question-origin.ts +24 -0
- package/src/settings.ts +7 -4
- package/src/skill-loader.ts +22 -10
- package/src/supervisor.ts +28 -8
- package/src/ui/agent-display.ts +2 -1
- package/src/ui/conversation-viewer.ts +49 -18
- package/src/ui/fleet-list.ts +125 -58
- package/src/ui/owner-group.ts +102 -0
- package/src/ui/schedule-menu.ts +14 -7
package/src/skill-loader.ts
CHANGED
|
@@ -30,37 +30,48 @@ export interface PreloadedSkill {
|
|
|
30
30
|
content: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export
|
|
34
|
-
|
|
33
|
+
export interface PreloadSkillsOptions {
|
|
34
|
+
/** Denied/unknown project trust excludes both project roots; user skills remain available. */
|
|
35
|
+
projectTrusted: boolean;
|
|
36
|
+
/** Preserve denied source provenance even through aliases of user skill roots. */
|
|
37
|
+
deniedResource?: (path: string) => boolean;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
function
|
|
40
|
+
export function preloadSkills(skillNames: string[], cwd: string, options: PreloadSkillsOptions): PreloadedSkill[] {
|
|
41
|
+
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd, options) }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadSkillContent(name: string, cwd: string, options: PreloadSkillsOptions): string {
|
|
38
45
|
if (isUnsafeName(name)) {
|
|
39
46
|
return `(Skill "${name}" skipped: name contains path traversal characters)`;
|
|
40
47
|
}
|
|
41
48
|
const roots = [
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
...(options.projectTrusted === true ? [
|
|
50
|
+
join(cwd, ".pi", "skills"), // project — Pi standard
|
|
51
|
+
join(cwd, ".agents", "skills"), // project — Agent Skills spec
|
|
52
|
+
] : []),
|
|
44
53
|
join(getAgentDir(), "skills"), // user — Pi standard
|
|
45
54
|
join(homedir(), ".agents", "skills"), // user — Agent Skills spec
|
|
46
55
|
join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
|
|
47
56
|
];
|
|
48
57
|
for (const root of roots) {
|
|
49
|
-
|
|
58
|
+
if (options.deniedResource?.(root)) continue;
|
|
59
|
+
const content = findInRoot(root, name, options.deniedResource);
|
|
50
60
|
if (content !== undefined) return content;
|
|
51
61
|
}
|
|
52
62
|
return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
function findInRoot(root: string, name: string): string | undefined {
|
|
65
|
+
function findInRoot(root: string, name: string, deniedResource?: (path: string) => boolean): string | undefined {
|
|
56
66
|
if (isSymlink(root)) return undefined; // reject symlinked roots entirely
|
|
57
|
-
const
|
|
67
|
+
const flatPath = join(root, `${name}.md`);
|
|
68
|
+
const flat = deniedResource?.(flatPath) ? undefined : safeReadFile(flatPath)?.trim();
|
|
58
69
|
if (flat !== undefined) return flat;
|
|
59
|
-
return findSkillDirectory(root, name);
|
|
70
|
+
return findSkillDirectory(root, name, deniedResource);
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
/** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
|
|
63
|
-
function findSkillDirectory(root: string, name: string): string | undefined {
|
|
74
|
+
function findSkillDirectory(root: string, name: string, deniedResource?: (path: string) => boolean): string | undefined {
|
|
64
75
|
if (!existsSync(root)) return undefined;
|
|
65
76
|
const queue: string[] = [root];
|
|
66
77
|
|
|
@@ -85,6 +96,7 @@ function findSkillDirectory(root: string, name: string): string | undefined {
|
|
|
85
96
|
// Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
|
|
86
97
|
const path = join(current, entry.name);
|
|
87
98
|
const skillMd = join(path, "SKILL.md");
|
|
99
|
+
if (deniedResource?.(skillMd)) continue;
|
|
88
100
|
const isSkillDir = existsSync(skillMd);
|
|
89
101
|
|
|
90
102
|
if (isSkillDir) {
|
package/src/supervisor.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* subagent that hit a genuine fork in the road could only guess, finish wrong,
|
|
6
6
|
* and have the guess discovered later in its result.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* By default the answer comes from the HUMAN. Our subagents
|
|
9
9
|
* share the parent's `ExtensionContext`, so the parent's UI is directly
|
|
10
10
|
* reachable — asking the person who is sitting there is both cheaper and more
|
|
11
11
|
* correct than delegating the judgement to another model, and it is the same
|
|
@@ -13,10 +13,14 @@
|
|
|
13
13
|
*
|
|
14
14
|
* That reachability is also why this is ~80 lines rather than a filesystem
|
|
15
15
|
* channel with a poller on each end: in-process, the question is one promise.
|
|
16
|
+
* Explicit background-only parent mode delegates ordinary clarification to the
|
|
17
|
+
* existing main session through parent-questions.ts; permission/approval tools
|
|
18
|
+
* still reach the human and no separate supervising model is created.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
19
22
|
import { Type } from "@sinclair/typebox";
|
|
23
|
+
import type { ParentQuestionHandler } from "./parent-questions.js";
|
|
20
24
|
import { sanitizeDisplayText, truncateCodePoints } from "./ui/safe-text.js";
|
|
21
25
|
|
|
22
26
|
/** Longest question a child may put to the parent, in characters. */
|
|
@@ -38,6 +42,8 @@ export interface SupervisorToolContext {
|
|
|
38
42
|
ask?: SupervisorAsk;
|
|
39
43
|
/** Actual type/name of the asking agent, for the prompt title. */
|
|
40
44
|
agentLabel: string;
|
|
45
|
+
/** Explicit background-only ordinary-clarification channel, not approval. */
|
|
46
|
+
parentQuestion?: ParentQuestionHandler;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
function textResult(text: string, isError = false) {
|
|
@@ -45,26 +51,30 @@ function textResult(text: string, isError = false) {
|
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
|
-
* Build
|
|
54
|
+
* Build `contact_supervisor`, or nothing without a human or explicit parent channel.
|
|
49
55
|
*
|
|
50
56
|
* Returning an empty array rather than a tool that always fails is deliberate,
|
|
51
57
|
* and matches how nested tools are handled: injecting a tool whose every call
|
|
52
58
|
* is an error spends context to teach the model an affordance it does not have.
|
|
53
|
-
*
|
|
59
|
+
* Default headless sessions therefore see no such tool. An explicit parent
|
|
60
|
+
* channel can answer ordinary clarification without a human UI.
|
|
54
61
|
*/
|
|
55
62
|
export function createSupervisorTool(context: SupervisorToolContext) {
|
|
56
|
-
if (!context.ask) return [];
|
|
63
|
+
if (!context.ask && !context.parentQuestion) return [];
|
|
57
64
|
const ask = context.ask;
|
|
58
65
|
|
|
59
66
|
return [
|
|
60
67
|
defineTool({
|
|
61
68
|
name: "contact_supervisor",
|
|
62
69
|
label: "Ask Supervisor",
|
|
63
|
-
description:
|
|
64
|
-
"Ask the
|
|
70
|
+
description: context.parentQuestion
|
|
71
|
+
? "Ask the main agent an ordinary task clarification and await its answer. Only use for missing information or ambiguous requirements you cannot resolve or defer. Never use this channel for permission, destructive-action confirmation, or approval; the main agent cannot authorize new actions or override a human denial. Prefer stating an assumption and continuing. A timeout, cancellation, or decline is no answer, not approval."
|
|
72
|
+
: "Ask the human who started you a question and wait for their answer. Use ONLY for a decision you cannot make yourself and cannot defer: an ambiguous requirement where the readings lead to materially different work, a destructive step that needs confirmation, or missing information nothing available to you can supply. It interrupts a person, so prefer stating an assumption and continuing. Returns their answer, or tells you they declined — in which case proceed on your best judgement and say what you assumed.",
|
|
65
73
|
parameters: Type.Object({
|
|
66
74
|
question: Type.String({
|
|
67
|
-
description:
|
|
75
|
+
description: context.parentQuestion
|
|
76
|
+
? "A self-contained ordinary clarification. Only this explicit bounded question reaches the main agent; do not include credentials or request permission."
|
|
77
|
+
: "The question, self-contained. The human cannot see your conversation.",
|
|
68
78
|
maxLength: MAX_QUESTION,
|
|
69
79
|
}),
|
|
70
80
|
options: Type.Optional(
|
|
@@ -74,7 +84,17 @@ export function createSupervisorTool(context: SupervisorToolContext) {
|
|
|
74
84
|
}),
|
|
75
85
|
),
|
|
76
86
|
}),
|
|
77
|
-
execute: async (_toolCallId, params) => {
|
|
87
|
+
execute: async (_toolCallId, params, signal) => {
|
|
88
|
+
if (context.parentQuestion) {
|
|
89
|
+
const reply = await context.parentQuestion(params.question, params.options, signal);
|
|
90
|
+
return {
|
|
91
|
+
content: [{ type: "text" as const, text: reply.status === "answered"
|
|
92
|
+
? `Main agent clarification (NOT human approval or a permission grant): ${reply.answer}`
|
|
93
|
+
: `No answer (${reply.reason}). Proceed only within existing authorization, state any safe assumption, or stop if human authorization is required.` }],
|
|
94
|
+
details: reply,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!ask) return textResult("No supervisor is available.", true);
|
|
78
98
|
// Both the question and any option labels are model-authored text about
|
|
79
99
|
// to be drawn into the user's terminal, so they are sanitized and
|
|
80
100
|
// bounded here — the same treatment any other child-supplied string
|
package/src/ui/agent-display.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* sanitizer on the way out.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
10
11
|
import { getConfig } from "../agent-types.js";
|
|
11
12
|
import type { AgentInvocation, SubagentType } from "../types.js";
|
|
12
13
|
import type { LifetimeUsage, SessionLike } from "../usage.js";
|
|
13
|
-
import {
|
|
14
|
+
import { truncateCodePoints } from "./safe-text.js";
|
|
14
15
|
|
|
15
16
|
// ---- Constants ----
|
|
16
17
|
|
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
10
|
+
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
10
11
|
import { renderAgentName } from "../agent-color.js";
|
|
11
12
|
import { extractText } from "../context.js";
|
|
12
13
|
import type { AgentRecordSnapshot } from "../types.js";
|
|
13
14
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
14
15
|
import type { Theme } from "./agent-display.js";
|
|
15
16
|
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-display.js";
|
|
16
|
-
import { PREVIEW_SCAN_LIMIT, safeTerminalText,
|
|
17
|
+
import { PREVIEW_SCAN_LIMIT, safeTerminalText, truncateCodePoints } from "./safe-text.js";
|
|
17
18
|
import { getAgentStatusColor, getAgentStatusLabel, getAgentStatusMark } from "./status-label.js";
|
|
18
19
|
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
19
20
|
|
|
@@ -56,6 +57,10 @@ function isBashExecutionMessage(message: { role: string }): message is BashExecu
|
|
|
56
57
|
&& typeof message.output === "string";
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: preserve existing void-returning steering callbacks.
|
|
61
|
+
export type SteerAcceptance = boolean | void;
|
|
62
|
+
export type SteerHandler = (message: string) => SteerAcceptance | Promise<SteerAcceptance>;
|
|
63
|
+
|
|
59
64
|
export class ConversationViewer implements Component {
|
|
60
65
|
private scrollOffset = 0;
|
|
61
66
|
private autoScroll = true;
|
|
@@ -67,6 +72,8 @@ export class ConversationViewer implements Component {
|
|
|
67
72
|
private keys: ViewerKeys;
|
|
68
73
|
/** Steering composer — present while the user is typing a message to the agent. */
|
|
69
74
|
private composer: Input | undefined;
|
|
75
|
+
private steerError: string | undefined;
|
|
76
|
+
private sending = false;
|
|
70
77
|
|
|
71
78
|
constructor(
|
|
72
79
|
private tui: TUI,
|
|
@@ -80,7 +87,7 @@ export class ConversationViewer implements Component {
|
|
|
80
87
|
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
|
|
81
88
|
keybindings?: ViewerKeybindings,
|
|
82
89
|
/** Send a steering message to the agent. Omitted → no compose affordance. */
|
|
83
|
-
private onSteer?:
|
|
90
|
+
private onSteer?: SteerHandler,
|
|
84
91
|
/** Retrieve a fresh public snapshot so the viewer reflects status changes. */
|
|
85
92
|
private getRecord?: () => AgentRecordSnapshot | undefined,
|
|
86
93
|
) {
|
|
@@ -92,16 +99,19 @@ export class ConversationViewer implements Component {
|
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
private refreshRecord(): AgentRecordSnapshot {
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
try {
|
|
103
|
+
const current = this.getRecord?.();
|
|
104
|
+
if (current) this.record = current;
|
|
105
|
+
} catch { /* a disposed owner cannot erase the retained snapshot */ }
|
|
97
106
|
return this.record;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
handleInput(data: string): void {
|
|
110
|
+
if (this.closed) return;
|
|
101
111
|
// While composing a steer message, the input owns all keys (Enter sends,
|
|
102
112
|
// Esc cancels — both wired in openComposer()). Editing keys flow through.
|
|
103
113
|
if (this.composer) {
|
|
104
|
-
this.composer.handleInput(data);
|
|
114
|
+
if (!this.sending || matchesKey(data, "escape")) this.composer.handleInput(data);
|
|
105
115
|
this.tui.requestRender();
|
|
106
116
|
return;
|
|
107
117
|
}
|
|
@@ -206,9 +216,9 @@ export class ConversationViewer implements Component {
|
|
|
206
216
|
const headerStats: string[] = [duration];
|
|
207
217
|
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
|
|
208
218
|
if (toolUses > 0) headerStats.unshift(`tools ${toolUses}`);
|
|
209
|
-
const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
|
|
210
|
-
if (tokens > 0) {
|
|
211
|
-
const percent = getSessionContextPercent(this.activity?.session);
|
|
219
|
+
const tokens = getLifetimeTotal(this.activity?.lifetimeUsage ?? this.record.lifetimeUsage);
|
|
220
|
+
if (tokens > 0 || this.record.compactionCount > 0) {
|
|
221
|
+
const percent = getSessionContextPercent(this.activity?.session ?? this.session);
|
|
212
222
|
headerStats.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
|
|
213
223
|
}
|
|
214
224
|
|
|
@@ -245,10 +255,8 @@ export class ConversationViewer implements Component {
|
|
|
245
255
|
const renderedComposer = this.composer.render(innerW)[0] ?? "";
|
|
246
256
|
const composerLine = renderedComposer.startsWith("> ") ? renderedComposer.slice(2) : renderedComposer;
|
|
247
257
|
lines.push(row(composerLine));
|
|
248
|
-
const composeHint =
|
|
249
|
-
|
|
250
|
-
const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
|
|
251
|
-
lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
|
|
258
|
+
const composeHint = this.sending ? "Sending..." : this.steerError ?? "Enter send · Esc cancel";
|
|
259
|
+
lines.push(row(th.fg(this.steerError ? "error" : "dim", composeHint)));
|
|
252
260
|
} else {
|
|
253
261
|
const sep = th.fg("dim", " · ");
|
|
254
262
|
const actions: string[] = [];
|
|
@@ -278,13 +286,13 @@ export class ConversationViewer implements Component {
|
|
|
278
286
|
/** Stoppable only when a stop handler exists and the agent is still active. */
|
|
279
287
|
private isStoppable(): boolean {
|
|
280
288
|
const record = this.refreshRecord();
|
|
281
|
-
return !!this.onStop && (record.status === "running" || record.status === "queued");
|
|
289
|
+
return !record.detached && !!this.onStop && (record.status === "running" || record.status === "queued");
|
|
282
290
|
}
|
|
283
291
|
|
|
284
292
|
/** Steerable only when a steer handler exists and the agent is still active. */
|
|
285
293
|
private canSteer(): boolean {
|
|
286
294
|
const record = this.refreshRecord();
|
|
287
|
-
return !!this.onSteer && (record.status === "running" || record.status === "queued");
|
|
295
|
+
return !record.detached && !!this.onSteer && (record.status === "running" || record.status === "queued");
|
|
288
296
|
}
|
|
289
297
|
|
|
290
298
|
/** Open the inline steering composer and route subsequent input to it. */
|
|
@@ -293,12 +301,34 @@ export class ConversationViewer implements Component {
|
|
|
293
301
|
input.focused = true;
|
|
294
302
|
input.onSubmit = (value: string) => {
|
|
295
303
|
const message = value.trim();
|
|
296
|
-
this.composer = undefined;
|
|
297
|
-
if (
|
|
304
|
+
if (!message) { this.composer = undefined; this.tui.requestRender(); return; }
|
|
305
|
+
if (!this.canSteer()) {
|
|
306
|
+
this.steerError = "Agent finished; draft retained · Esc cancel";
|
|
307
|
+
this.tui.requestRender();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
this.steerError = undefined;
|
|
311
|
+
const settle = (accepted: SteerAcceptance): void => {
|
|
312
|
+
if (this.closed || this.composer !== input) return;
|
|
313
|
+
this.sending = false;
|
|
314
|
+
if (accepted === false) this.steerError = "Steering rejected; draft retained · Esc cancel";
|
|
315
|
+
else this.composer = undefined;
|
|
316
|
+
this.tui.requestRender();
|
|
317
|
+
};
|
|
318
|
+
const reject = (): void => settle(false);
|
|
319
|
+
try {
|
|
320
|
+
const result = this.onSteer?.(message);
|
|
321
|
+
if (result && typeof result === "object") {
|
|
322
|
+
this.sending = true;
|
|
323
|
+
void result.then(settle, reject);
|
|
324
|
+
} else settle(result);
|
|
325
|
+
} catch { reject(); }
|
|
298
326
|
this.tui.requestRender();
|
|
299
327
|
};
|
|
300
328
|
input.onEscape = () => {
|
|
301
329
|
this.composer = undefined;
|
|
330
|
+
this.steerError = undefined;
|
|
331
|
+
this.sending = false;
|
|
302
332
|
this.tui.requestRender();
|
|
303
333
|
};
|
|
304
334
|
this.composer = input;
|
|
@@ -336,14 +366,15 @@ export class ConversationViewer implements Component {
|
|
|
336
366
|
const { modelName, tags } = buildInvocationTags(this.record.invocation);
|
|
337
367
|
const parts = modelName ? [modelName, ...tags] : tags;
|
|
338
368
|
if (parts.length === 0) return undefined;
|
|
339
|
-
return this.theme.fg("dim", ` invocation · ${parts.join(" · ")}`);
|
|
369
|
+
return this.theme.fg("dim", ` invocation · ${parts.map(part => sanitizeDisplayText(part)).join(" · ")}`);
|
|
340
370
|
}
|
|
341
371
|
|
|
342
372
|
private buildContentLines(width: number): string[] {
|
|
343
373
|
if (width <= 0) return [];
|
|
344
374
|
|
|
345
375
|
const th = this.theme;
|
|
346
|
-
|
|
376
|
+
let messages: AgentSession["messages"];
|
|
377
|
+
try { messages = this.session.messages; } catch { messages = []; }
|
|
347
378
|
const lines: string[] = [];
|
|
348
379
|
|
|
349
380
|
if (messages.length === 0) {
|