@jwilger/pi-development-system 0.10.5 → 0.11.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/agents/architect.md +66 -0
- package/agents/coder.md +57 -0
- package/agents/researcher.md +60 -0
- package/agents/reviewer.md +53 -0
- package/agents/tasker.md +58 -0
- package/agents/writer.md +39 -0
- package/extensions/development-system.ts +2 -0
- package/package.json +5 -3
- package/src/core/test-runner.ts +1 -1
- package/src/gates/red-first-guard.ts +4 -1
- package/src/subagents/VENDORED.md +38 -0
- package/src/subagents/index.ts +544 -0
- package/src/subagents/orch/inherited-tools.ts +85 -0
- package/src/subagents/orch/mailbox.ts +262 -0
- package/src/subagents/orch/manager.ts +765 -0
- package/src/subagents/orch/paths.ts +74 -0
- package/src/subagents/orch/prompt.ts +25 -0
- package/src/subagents/orch/runtime.ts +784 -0
- package/src/subagents/orch/tools.ts +166 -0
- package/src/subagents/orch/transcript.ts +187 -0
- package/src/subagents/prefs/agent-import.ts +246 -0
- package/src/subagents/prefs/config.ts +1029 -0
- package/src/subagents/prefs/import-discovery.ts +644 -0
- package/src/subagents/prefs/import-instructions.ts +157 -0
- package/src/subagents/prefs/models.ts +78 -0
- package/src/subagents/prefs/settings.ts +371 -0
- package/src/subagents/types.ts +200 -0
- package/src/subagents/ui/agent-navigation-editor.ts +210 -0
- package/src/subagents/ui/dialog.ts +651 -0
- package/src/subagents/ui/import-picker.ts +232 -0
- package/src/subagents/ui/live-agent-view.ts +572 -0
- package/src/subagents/ui/model-picker.ts +452 -0
- package/src/subagents/ui/settings-ui.ts +329 -0
- package/src/subagents/ui/status-ui.ts +545 -0
- package/src/subagents/ui/thread-tree.ts +135 -0
- package/src/subagents/ui/ui.ts +1507 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ThreadManager } from "./manager.ts";
|
|
5
|
+
import { ModelPreferenceError } from "../prefs/models.ts";
|
|
6
|
+
import type { AgentType, ResolvedAgentSettings, ThreadView } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
const path = Type.String({ description: "Absolute agent path, or name relative to the caller" });
|
|
9
|
+
const text = Type.String({ minLength: 1 });
|
|
10
|
+
function compact(thread: ThreadView): ThreadView & { outputTruncated?: boolean } {
|
|
11
|
+
if (thread.output && thread.output.length > 16000)
|
|
12
|
+
return { ...thread, output: thread.output.slice(0, 16000), outputTruncated: true };
|
|
13
|
+
return thread;
|
|
14
|
+
}
|
|
15
|
+
function result(value: unknown) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
|
|
18
|
+
details: value,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** All threads see the same verbs, with caller identity bound by closure, never model input. */
|
|
23
|
+
export function agentTools(
|
|
24
|
+
getManager: () => ThreadManager,
|
|
25
|
+
caller: string,
|
|
26
|
+
getTypes: () => AgentType[],
|
|
27
|
+
resolveSettings: (type: AgentType) => ResolvedAgentSettings,
|
|
28
|
+
): ToolDefinition[] {
|
|
29
|
+
const threads = () => getManager().scope(caller);
|
|
30
|
+
const make = (
|
|
31
|
+
name: string,
|
|
32
|
+
description: string,
|
|
33
|
+
parameters: any,
|
|
34
|
+
execute: (params: any, signal?: AbortSignal) => Promise<any> | any,
|
|
35
|
+
): ToolDefinition => ({
|
|
36
|
+
name,
|
|
37
|
+
label: name,
|
|
38
|
+
description,
|
|
39
|
+
parameters,
|
|
40
|
+
execute: async (_id, params, signal) => execute(params, signal),
|
|
41
|
+
});
|
|
42
|
+
return [
|
|
43
|
+
make(
|
|
44
|
+
"agent_types",
|
|
45
|
+
"List agent names, descriptions, resolved models and thinking levels for new children of this caller. Reflects current configuration; call before choosing a type. Other parent paths may inherit different settings.",
|
|
46
|
+
Type.Object({}),
|
|
47
|
+
() => {
|
|
48
|
+
const entries = getTypes().map((type) => {
|
|
49
|
+
const { name, description } = type;
|
|
50
|
+
try {
|
|
51
|
+
return { name, description, ...resolveSettings(type) };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return {
|
|
54
|
+
name,
|
|
55
|
+
description,
|
|
56
|
+
error:
|
|
57
|
+
error instanceof ModelPreferenceError
|
|
58
|
+
? error.summary
|
|
59
|
+
: error instanceof Error
|
|
60
|
+
? error.message
|
|
61
|
+
: String(error),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
type: "text" as const,
|
|
69
|
+
text:
|
|
70
|
+
entries
|
|
71
|
+
.map((entry) =>
|
|
72
|
+
"error" in entry
|
|
73
|
+
? `${entry.name} (unavailable): ${entry.description}\n ${entry.error}`
|
|
74
|
+
: `${entry.name} (${entry.model}, thinking: ${entry.thinkingLevel}): ${entry.description}`,
|
|
75
|
+
)
|
|
76
|
+
.join("\n") || "No agent types available.",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
details: entries,
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
make(
|
|
84
|
+
"agent_spawn",
|
|
85
|
+
"Spawn a named child. The lexical parent, NOT the caller, supplies a context snapshot. /root is the main thread; independent roots have no inherited context. Foreground waits by default; wait:false runs detached. For parallel work, spawn every independent sibling with wait:false before calling agent_wait; child agents with delegation tools should do the same. Existing paths are retained and can be resumed with agent_steer. Use agent_types to discover current types.",
|
|
86
|
+
Type.Object({
|
|
87
|
+
path: Type.String({
|
|
88
|
+
description:
|
|
89
|
+
"Task-based kebab-case path, not the agent type (e.g. /root/controller-security-research), or a task name relative to the caller",
|
|
90
|
+
}),
|
|
91
|
+
type: text,
|
|
92
|
+
task: text,
|
|
93
|
+
wait: Type.Optional(Type.Boolean()),
|
|
94
|
+
}),
|
|
95
|
+
async (params, signal) => result(compact(await threads().spawn(params, signal))),
|
|
96
|
+
),
|
|
97
|
+
make(
|
|
98
|
+
"agent_wait",
|
|
99
|
+
"Wait for a descendant to complete, fail, stop or pause. Paused means no final answer handback. Cancellation/timeouts do not stop the child; poll status or wait again.",
|
|
100
|
+
Type.Object({
|
|
101
|
+
path,
|
|
102
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 3600000 })),
|
|
103
|
+
}),
|
|
104
|
+
async (params, signal) =>
|
|
105
|
+
result(compact(await threads().wait(params.path, params.timeoutMs, signal))),
|
|
106
|
+
),
|
|
107
|
+
make(
|
|
108
|
+
"agent_steer",
|
|
109
|
+
"Send input to a descendant. Working threads receive queued steering; completed, stopped and paused threads resume the SAME session with its previous context. Returns immediately; use agent_wait for the result.",
|
|
110
|
+
Type.Object({ path, message: text }),
|
|
111
|
+
async (params) => result(compact(await threads().steer(params.path, params.message))),
|
|
112
|
+
),
|
|
113
|
+
make(
|
|
114
|
+
"agent_status",
|
|
115
|
+
"Read a thread's status or list visible descendants. Root can inspect all trees. Completion retains the session; pause retains it without final answer handback.",
|
|
116
|
+
Type.Object({ path: Type.Optional(path) }),
|
|
117
|
+
(params) =>
|
|
118
|
+
result(params.path ? compact(threads().get(params.path)) : threads().list().map(compact)),
|
|
119
|
+
),
|
|
120
|
+
make(
|
|
121
|
+
"agent_update",
|
|
122
|
+
"Child-only: send progress to the parent without ending this task. Delivery is recorded and displayed but does not interrupt or automatically restart the parent model.",
|
|
123
|
+
Type.Object({ message: Type.String({ minLength: 1, maxLength: 8000 }) }),
|
|
124
|
+
(params) => result(compact(threads().update(params.message))),
|
|
125
|
+
),
|
|
126
|
+
make(
|
|
127
|
+
"agent_pause",
|
|
128
|
+
"Child-only: stop at this turn boundary WITHOUT returning a final answer. Keep the SAME session for user/parent input via agent_steer. To finish and hand back an answer instead, simply return your final assistant answer. Both states can be resumed.",
|
|
129
|
+
Type.Object({ reason: text }),
|
|
130
|
+
(params) => ({
|
|
131
|
+
...result({
|
|
132
|
+
path: caller,
|
|
133
|
+
pauseRequested: true,
|
|
134
|
+
status: threads().pause(params.reason).status,
|
|
135
|
+
}),
|
|
136
|
+
terminate: true,
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
make(
|
|
140
|
+
"agent_stop",
|
|
141
|
+
"Stop a descendant and its working children. Session history is retained; resume with agent_steer. This is cooperative cancellation, not sandbox process termination.",
|
|
142
|
+
Type.Object({ path }),
|
|
143
|
+
async (params) => result(compact(await threads().stop(params.path))),
|
|
144
|
+
),
|
|
145
|
+
make(
|
|
146
|
+
"agent_output",
|
|
147
|
+
"Read completed thread output in character pages. Paused threads deliberately have no final answer handback. All sessions can be viewed by the user with /agents tree; select a thread and press Enter to inspect it.",
|
|
148
|
+
Type.Object({
|
|
149
|
+
path,
|
|
150
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
151
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 16000 })),
|
|
152
|
+
}),
|
|
153
|
+
(params) => {
|
|
154
|
+
const output = threads().output(params.path),
|
|
155
|
+
offset = params.offset ?? 0,
|
|
156
|
+
limit = params.limit ?? 8000;
|
|
157
|
+
return result({
|
|
158
|
+
path: threads().get(params.path).path,
|
|
159
|
+
text: output.slice(offset, offset + limit),
|
|
160
|
+
totalCharacters: output.length,
|
|
161
|
+
nextOffset: offset + limit < output.length ? offset + limit : null,
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
|
|
2
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type {
|
|
5
|
+
TranscriptListener,
|
|
6
|
+
TranscriptObservation,
|
|
7
|
+
TranscriptSnapshot,
|
|
8
|
+
TranscriptToolState,
|
|
9
|
+
} from "../types.ts";
|
|
10
|
+
|
|
11
|
+
/** Clone once at the ownership boundary, then share immutable state across observers. */
|
|
12
|
+
export function immutableTranscriptValue<T>(value: T): T {
|
|
13
|
+
const copy = structuredClone(value);
|
|
14
|
+
const seen = new WeakSet<object>();
|
|
15
|
+
const freeze = (item: unknown): void => {
|
|
16
|
+
if (!item || typeof item !== "object" || seen.has(item)) return;
|
|
17
|
+
seen.add(item);
|
|
18
|
+
for (const child of Object.values(item)) freeze(child);
|
|
19
|
+
// Freezing populated typed-array views throws. Their storage was already cloned.
|
|
20
|
+
if (!ArrayBuffer.isView(item)) Object.freeze(item);
|
|
21
|
+
};
|
|
22
|
+
freeze(copy);
|
|
23
|
+
return copy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** SDK event projection. Committed history is copied at boundaries, never for each token. */
|
|
27
|
+
export class TranscriptChannel {
|
|
28
|
+
private revision = 0;
|
|
29
|
+
private messages: readonly AgentMessage[];
|
|
30
|
+
private assistant: AgentMessage | null = null;
|
|
31
|
+
private tools = new Map<string, TranscriptToolState>();
|
|
32
|
+
private listeners = new Set<TranscriptListener>();
|
|
33
|
+
private current: TranscriptSnapshot;
|
|
34
|
+
private disposed = false;
|
|
35
|
+
private error: string | undefined;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readMessages: () => readonly AgentMessage[],
|
|
39
|
+
private inheritedCount: number,
|
|
40
|
+
) {
|
|
41
|
+
this.messages = immutableTranscriptValue(readMessages());
|
|
42
|
+
this.current = this.snapshot();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
observe(listener: TranscriptListener): TranscriptObservation {
|
|
46
|
+
if (this.disposed) throw new Error("Transcript channel is disposed");
|
|
47
|
+
// No await between registration and snapshot: a finalization cannot fall in a gap.
|
|
48
|
+
const registered: TranscriptListener = (snapshot) => listener(snapshot);
|
|
49
|
+
this.listeners.add(registered);
|
|
50
|
+
return { snapshot: this.current, unsubscribe: () => this.listeners.delete(registered) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
accept(event: AgentSessionEvent): void {
|
|
54
|
+
if (this.disposed) return;
|
|
55
|
+
try {
|
|
56
|
+
this.reduce(event);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// Custom tool details can be non-cloneable. Observation must still never fail the run.
|
|
59
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
60
|
+
this.publish();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private reduce(event: AgentSessionEvent): void {
|
|
65
|
+
this.error = undefined;
|
|
66
|
+
switch (event.type) {
|
|
67
|
+
case "message_start":
|
|
68
|
+
case "message_update":
|
|
69
|
+
if (event.message.role !== "assistant") return;
|
|
70
|
+
this.assistant = immutableTranscriptValue(event.message);
|
|
71
|
+
break;
|
|
72
|
+
case "message_end":
|
|
73
|
+
if (event.message.role === "assistant") this.assistant = null;
|
|
74
|
+
if (event.message.role === "toolResult") this.tools.delete(event.message.toolCallId);
|
|
75
|
+
this.messages = immutableTranscriptValue(this.readMessages());
|
|
76
|
+
break;
|
|
77
|
+
case "tool_execution_start":
|
|
78
|
+
this.tools.set(
|
|
79
|
+
event.toolCallId,
|
|
80
|
+
immutableTranscriptValue({
|
|
81
|
+
toolCallId: event.toolCallId,
|
|
82
|
+
toolName: event.toolName,
|
|
83
|
+
parentToolCallId: event.parentToolCallId,
|
|
84
|
+
args: event.args,
|
|
85
|
+
state: "running" as const,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
break;
|
|
89
|
+
case "tool_execution_update":
|
|
90
|
+
this.tools.set(
|
|
91
|
+
event.toolCallId,
|
|
92
|
+
immutableTranscriptValue({
|
|
93
|
+
...this.tools.get(event.toolCallId),
|
|
94
|
+
toolCallId: event.toolCallId,
|
|
95
|
+
toolName: event.toolName,
|
|
96
|
+
parentToolCallId: event.parentToolCallId,
|
|
97
|
+
args: event.args,
|
|
98
|
+
result: event.partialResult,
|
|
99
|
+
state: "running" as const,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
break;
|
|
103
|
+
case "tool_execution_end":
|
|
104
|
+
this.tools.set(
|
|
105
|
+
event.toolCallId,
|
|
106
|
+
immutableTranscriptValue({
|
|
107
|
+
...this.tools.get(event.toolCallId),
|
|
108
|
+
toolCallId: event.toolCallId,
|
|
109
|
+
toolName: event.toolName,
|
|
110
|
+
parentToolCallId: event.parentToolCallId,
|
|
111
|
+
args: this.tools.get(event.toolCallId)?.args,
|
|
112
|
+
result: event.result,
|
|
113
|
+
isError: event.isError,
|
|
114
|
+
state: "completed" as const,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
// Nested calls have no transcript message. Bound their transient completed retention.
|
|
118
|
+
for (const [id, tool] of this.tools) {
|
|
119
|
+
if (this.tools.size <= 128) break;
|
|
120
|
+
if (tool.state === "completed") this.tools.delete(id);
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
case "compaction_end":
|
|
124
|
+
// Compaction may replace/reorder history; do not approximate it with append-only deltas.
|
|
125
|
+
if (event.result && !event.aborted) this.inheritedCount = 0;
|
|
126
|
+
this.messages = immutableTranscriptValue(this.readMessages());
|
|
127
|
+
break;
|
|
128
|
+
case "agent_end":
|
|
129
|
+
case "agent_settled":
|
|
130
|
+
this.assistant = null;
|
|
131
|
+
this.tools.clear();
|
|
132
|
+
this.messages = immutableTranscriptValue(this.readMessages());
|
|
133
|
+
break;
|
|
134
|
+
case "auto_retry_start":
|
|
135
|
+
case "auto_retry_end":
|
|
136
|
+
this.messages = immutableTranscriptValue(this.readMessages());
|
|
137
|
+
break;
|
|
138
|
+
default:
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.publish();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Non-turn custom delivery is not guaranteed to use core message events. */
|
|
145
|
+
reconcile(): void {
|
|
146
|
+
if (this.disposed) return;
|
|
147
|
+
try {
|
|
148
|
+
this.messages = immutableTranscriptValue(this.readMessages());
|
|
149
|
+
this.error = undefined;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
152
|
+
}
|
|
153
|
+
this.publish();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
dispose(): void {
|
|
157
|
+
this.disposed = true;
|
|
158
|
+
this.listeners.clear();
|
|
159
|
+
this.tools.clear();
|
|
160
|
+
this.assistant = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private snapshot(): TranscriptSnapshot {
|
|
164
|
+
return Object.freeze({
|
|
165
|
+
revision: this.revision,
|
|
166
|
+
generation: 0,
|
|
167
|
+
messages: this.messages,
|
|
168
|
+
assistant: this.assistant,
|
|
169
|
+
tools: Object.freeze([...this.tools.values()]),
|
|
170
|
+
inheritedCount: Math.min(this.inheritedCount, this.messages.length),
|
|
171
|
+
...(this.error ? { error: this.error } : {}),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private publish(): void {
|
|
176
|
+
this.revision++;
|
|
177
|
+
this.current = this.snapshot();
|
|
178
|
+
for (const listener of this.listeners) {
|
|
179
|
+
// Watching is read-only: a broken renderer must not fail the agent's execution.
|
|
180
|
+
try {
|
|
181
|
+
listener(this.current);
|
|
182
|
+
} catch {
|
|
183
|
+
/* observer owns its error reporting */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
|
|
2
|
+
import { constants, lstatSync, mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { AGENT_COLORS, type ConfigStore } from "./config.ts";
|
|
7
|
+
import { discoverImportCandidates, type ImportCandidate } from "./import-discovery.ts";
|
|
8
|
+
import { selectImportAgents } from "../ui/import-picker.ts";
|
|
9
|
+
import { MIGRATION_INSTRUCTIONS } from "./import-instructions.ts";
|
|
10
|
+
import { dialogText } from "../ui/dialog.ts";
|
|
11
|
+
|
|
12
|
+
export const IMPORT_REQUEST_PREFIX =
|
|
13
|
+
"Import ONLY the agents I selected in the pi-subagent-manager migration picker.";
|
|
14
|
+
|
|
15
|
+
export const IMPORT_CHILD_TOOLS = [
|
|
16
|
+
"read",
|
|
17
|
+
"bash",
|
|
18
|
+
"powershell",
|
|
19
|
+
"edit",
|
|
20
|
+
"write",
|
|
21
|
+
"grep",
|
|
22
|
+
"find",
|
|
23
|
+
"ls",
|
|
24
|
+
"agent_types",
|
|
25
|
+
"agent_spawn",
|
|
26
|
+
"agent_wait",
|
|
27
|
+
"agent_steer",
|
|
28
|
+
"agent_status",
|
|
29
|
+
"agent_update",
|
|
30
|
+
"agent_pause",
|
|
31
|
+
"agent_stop",
|
|
32
|
+
"agent_output",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// This records onboarding only. Limits remain in subagent-manager/settings.json.
|
|
36
|
+
const OFFER_MARKER = ".import-offered";
|
|
37
|
+
|
|
38
|
+
function statIfPresent(path: string) {
|
|
39
|
+
try {
|
|
40
|
+
const stat = lstatSync(path);
|
|
41
|
+
if (stat.isSymbolicLink()) throw new Error(`Unsafe import-state symlink: ${path}`);
|
|
42
|
+
return stat;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stateDirectory(agentDir: string, create: boolean): string | undefined {
|
|
50
|
+
const directory = join(agentDir, "subagent-manager");
|
|
51
|
+
for (const path of [agentDir, directory]) {
|
|
52
|
+
let stat = statIfPresent(path);
|
|
53
|
+
if (!stat && create) {
|
|
54
|
+
// The host owns agentDir. Do not recursively create or follow unchecked parents.
|
|
55
|
+
mkdirSync(path);
|
|
56
|
+
stat = statIfPresent(path);
|
|
57
|
+
}
|
|
58
|
+
if (!stat) return undefined;
|
|
59
|
+
if (!stat.isDirectory()) throw new Error(`Import-state directory is not a directory: ${path}`);
|
|
60
|
+
}
|
|
61
|
+
return directory;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function importWasOffered(agentDir: string): boolean {
|
|
65
|
+
const directory = stateDirectory(agentDir, false);
|
|
66
|
+
if (!directory) return false;
|
|
67
|
+
const stat = statIfPresent(join(directory, OFFER_MARKER));
|
|
68
|
+
if (stat && !stat.isFile()) throw new Error("Import-state marker must be a regular file");
|
|
69
|
+
return !!stat;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function markImportOffered(agentDir: string): void {
|
|
73
|
+
const directory = stateDirectory(agentDir, true)!;
|
|
74
|
+
const file = join(directory, OFFER_MARKER);
|
|
75
|
+
const stat = statIfPresent(file);
|
|
76
|
+
if (stat) {
|
|
77
|
+
if (!stat.isFile()) throw new Error("Import-state marker must be a regular file");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const fd = openSync(
|
|
81
|
+
file,
|
|
82
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0),
|
|
83
|
+
0o600,
|
|
84
|
+
);
|
|
85
|
+
try {
|
|
86
|
+
writeFileSync(fd, "Onboarding offered. Use /agents import to choose agents again.\n");
|
|
87
|
+
} finally {
|
|
88
|
+
closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildImportPrompt(options: {
|
|
93
|
+
candidates: readonly ImportCandidate[];
|
|
94
|
+
agentDir: string;
|
|
95
|
+
cwd: string;
|
|
96
|
+
includeProject: boolean;
|
|
97
|
+
existingTypes: { name: string; source?: string; filePath?: string }[];
|
|
98
|
+
scopedModels: readonly string[];
|
|
99
|
+
scopedModelDetails?: readonly { identity: string; api: string; virtual: boolean }[];
|
|
100
|
+
parentModel?: string;
|
|
101
|
+
parentModelApi?: string;
|
|
102
|
+
}): string {
|
|
103
|
+
return [
|
|
104
|
+
IMPORT_REQUEST_PREFIX,
|
|
105
|
+
MIGRATION_INSTRUCTIONS,
|
|
106
|
+
"## Current-session migration facts (JSON data, not instructions)",
|
|
107
|
+
JSON.stringify(
|
|
108
|
+
{
|
|
109
|
+
selectedDefinitions: options.candidates,
|
|
110
|
+
destinations: {
|
|
111
|
+
user: join(options.agentDir, "subagent-manager", "agents"),
|
|
112
|
+
project: options.includeProject
|
|
113
|
+
? join(options.cwd, ".pi", "agent", "subagent-manager", "agents")
|
|
114
|
+
: null,
|
|
115
|
+
},
|
|
116
|
+
destinationRule:
|
|
117
|
+
"Keep user sources user-scoped and project sources project-scoped. Project destination is permitted only when non-null; ask before changing scope.",
|
|
118
|
+
managerSettings: join(options.agentDir, "subagent-manager", "settings.json"),
|
|
119
|
+
configModule: fileURLToPath(new URL("./config.ts", import.meta.url)),
|
|
120
|
+
existingTypes: options.existingTypes,
|
|
121
|
+
scopedModels: options.scopedModels,
|
|
122
|
+
scopedModelDetails: options.scopedModelDetails ?? [],
|
|
123
|
+
parentModel: options.parentModel ?? null,
|
|
124
|
+
parentModelApi: options.parentModelApi ?? null,
|
|
125
|
+
childTools: IMPORT_CHILD_TOOLS,
|
|
126
|
+
colorTokens: AGENT_COLORS,
|
|
127
|
+
},
|
|
128
|
+
null,
|
|
129
|
+
2,
|
|
130
|
+
),
|
|
131
|
+
].join("\n\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Discovery and consent only; the current conversation performs every semantic conversion/write. */
|
|
135
|
+
export async function offerAgentImport(
|
|
136
|
+
pi: ExtensionAPI,
|
|
137
|
+
ctx: ExtensionContext,
|
|
138
|
+
options: {
|
|
139
|
+
agentDir: string;
|
|
140
|
+
store: ConfigStore;
|
|
141
|
+
firstRun?: boolean;
|
|
142
|
+
homeDir?: string;
|
|
143
|
+
extraAgentDirs?: string;
|
|
144
|
+
select?: typeof selectImportAgents;
|
|
145
|
+
},
|
|
146
|
+
): Promise<boolean> {
|
|
147
|
+
const firstRun = options.firstRun ?? false;
|
|
148
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
149
|
+
if (!firstRun)
|
|
150
|
+
ctx.ui.notify(
|
|
151
|
+
"Agent import requires TUI mode. Run /agents import in an interactive session.",
|
|
152
|
+
"warning",
|
|
153
|
+
);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
if (firstRun && importWasOffered(options.agentDir)) return false;
|
|
158
|
+
const found = discoverImportCandidates({
|
|
159
|
+
cwd: ctx.cwd,
|
|
160
|
+
agentDir: options.agentDir,
|
|
161
|
+
includeProject: ctx.isProjectTrusted(),
|
|
162
|
+
homeDir: options.homeDir,
|
|
163
|
+
extraAgentDirs: options.extraAgentDirs,
|
|
164
|
+
});
|
|
165
|
+
if (found.diagnostics.length)
|
|
166
|
+
ctx.ui.notify(dialogText(found.diagnostics.join("\n")), "warning");
|
|
167
|
+
const remember = () => {
|
|
168
|
+
try {
|
|
169
|
+
markImportOffered(options.agentDir);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
ctx.ui.notify(
|
|
172
|
+
dialogText(`Could not save import onboarding state: ${String(error)}`),
|
|
173
|
+
"warning",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
if (!found.candidates.length) {
|
|
178
|
+
// Retry an incomplete scan next startup, rather than permanently hiding unreadable definitions.
|
|
179
|
+
if (!found.diagnostics.length) remember();
|
|
180
|
+
if (!firstRun)
|
|
181
|
+
ctx.ui.notify(
|
|
182
|
+
"No external agent definitions found. Package-provided defaults are not scanned.",
|
|
183
|
+
"info",
|
|
184
|
+
);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
firstRun &&
|
|
189
|
+
!(await ctx.ui.confirm(
|
|
190
|
+
"Import existing agents?",
|
|
191
|
+
`Found ${found.candidates.length} external agent definitions. Choose individual agents in a checkbox picker, then the current session model will migrate only your selection. Originals stay unchanged. You can also use /agents import later.`,
|
|
192
|
+
))
|
|
193
|
+
) {
|
|
194
|
+
remember();
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const ids = await (options.select ?? selectImportAgents)(
|
|
198
|
+
ctx,
|
|
199
|
+
found.candidates.map((candidate) => ({
|
|
200
|
+
id: candidate.id,
|
|
201
|
+
label: `${candidate.name} (${candidate.scope})`,
|
|
202
|
+
detail: `${candidate.path}\n${candidate.description}`,
|
|
203
|
+
})),
|
|
204
|
+
);
|
|
205
|
+
if (!ids?.length) {
|
|
206
|
+
remember();
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const selected = new Set(ids);
|
|
210
|
+
const candidates = found.candidates.filter((candidate) => selected.has(candidate.id));
|
|
211
|
+
if (!candidates.length) return false;
|
|
212
|
+
options.store.reload();
|
|
213
|
+
const prompt = buildImportPrompt({
|
|
214
|
+
candidates,
|
|
215
|
+
agentDir: options.agentDir,
|
|
216
|
+
cwd: ctx.cwd,
|
|
217
|
+
includeProject: ctx.isProjectTrusted(),
|
|
218
|
+
existingTypes: options.store
|
|
219
|
+
.list()
|
|
220
|
+
.map(({ name, source, filePath }) => ({ name, source, filePath })),
|
|
221
|
+
scopedModels: (ctx.scopedModels ?? []).map(({ model }) => `${model.provider}/${model.id}`),
|
|
222
|
+
scopedModelDetails: (ctx.scopedModels ?? []).map(({ model }) => ({
|
|
223
|
+
identity: `${model.provider}/${model.id}`,
|
|
224
|
+
api: model.api,
|
|
225
|
+
virtual: model.api === "pi-virtual",
|
|
226
|
+
})),
|
|
227
|
+
parentModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
228
|
+
parentModelApi: ctx.model?.api,
|
|
229
|
+
});
|
|
230
|
+
// This starts (or queues) a turn in the active root session, not another SDK session.
|
|
231
|
+
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
232
|
+
// sendUserMessage is fire-and-forget. The root before_agent_start hook acknowledges
|
|
233
|
+
// accepted requests; authentication/model failures must leave first-run retryable.
|
|
234
|
+
ctx.ui.notify(
|
|
235
|
+
`Asked the current session model to import ${candidates.length} selected agent(s).`,
|
|
236
|
+
"info",
|
|
237
|
+
);
|
|
238
|
+
return true;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
ctx.ui.notify(
|
|
241
|
+
dialogText(`Agent import failed: ${String(error)}. Retry with /agents import.`),
|
|
242
|
+
"warning",
|
|
243
|
+
);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|