@rosetears/aili-pi 0.1.9 → 0.1.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.
Files changed (57) hide show
  1. package/README.md +6 -6
  2. package/THIRD_PARTY_NOTICES.md +12 -12
  3. package/docs/persistent-agents.md +114 -0
  4. package/manifests/adapter-evidence.json +53 -20
  5. package/manifests/capabilities.json +3 -3
  6. package/manifests/live-verification.json +18 -19
  7. package/manifests/provenance.json +12 -12
  8. package/manifests/roles.json +270 -57
  9. package/manifests/sbom.json +1 -128
  10. package/manifests/skill-compatibility.json +57 -61
  11. package/manifests/subagent-provenance.json +8 -15
  12. package/package.json +3 -3
  13. package/roles/agent-evaluator.md +8 -3
  14. package/roles/ai-regression-scout.md +8 -3
  15. package/roles/browser-qa-runner.md +8 -3
  16. package/roles/code-reviewer.md +8 -3
  17. package/roles/code-scout.md +8 -3
  18. package/roles/convergence-reviewer.md +8 -3
  19. package/roles/doc-researcher.md +8 -3
  20. package/roles/e2e-artifact-runner.md +8 -3
  21. package/roles/general.md +49 -0
  22. package/roles/implementer.md +8 -3
  23. package/roles/opensource-sanitizer.md +8 -3
  24. package/roles/plan-auditor.md +8 -3
  25. package/roles/pr-test-analyzer.md +8 -3
  26. package/roles/security-auditor.md +8 -3
  27. package/roles/silent-failure-reviewer.md +8 -3
  28. package/roles/spec-miner.md +8 -3
  29. package/roles/test-coverage-reviewer.md +8 -3
  30. package/roles/test-engineer.md +8 -3
  31. package/roles/web-performance-auditor.md +8 -3
  32. package/roles/web-researcher.md +8 -3
  33. package/scripts/sync-roles.ts +186 -24
  34. package/src/runtime/doctor.ts +38 -10
  35. package/src/runtime/global-resources.ts +5 -1
  36. package/src/runtime/index.ts +2 -2
  37. package/src/runtime/persistent-agents/hub.ts +429 -0
  38. package/src/runtime/persistent-agents/model-selection.ts +363 -0
  39. package/src/runtime/persistent-agents/output-delivery.ts +356 -0
  40. package/src/runtime/persistent-agents/permission.ts +223 -0
  41. package/src/runtime/persistent-agents/policy.ts +311 -0
  42. package/src/runtime/persistent-agents/production.ts +569 -0
  43. package/src/runtime/persistent-agents/runtime.ts +164 -0
  44. package/src/runtime/persistent-agents/sandbox.ts +68 -0
  45. package/src/runtime/persistent-agents/scheduler.ts +190 -0
  46. package/src/runtime/persistent-agents/session-factory.ts +184 -0
  47. package/src/runtime/persistent-agents/storage.ts +775 -0
  48. package/src/runtime/persistent-agents/task-coordinator.ts +460 -0
  49. package/src/runtime/persistent-agents/task-schema.ts +141 -0
  50. package/src/runtime/persistent-agents/types.ts +134 -0
  51. package/src/runtime/persistent-agents/workspace.ts +335 -0
  52. package/src/runtime/registry.ts +28 -32
  53. package/src/runtime/roles.ts +211 -18
  54. package/src/runtime/rose-context.ts +1 -1
  55. package/templates/APPEND_SYSTEM.md +1 -1
  56. package/upstream/opencode-global-agents.lock.json +1 -1
  57. package/src/runtime/subagents.ts +0 -249
@@ -0,0 +1,569 @@
1
+ import { getAgentDir, type AgentSession, type CreateAgentSessionOptions, type ExtensionAPI, type ExtensionContext, type SessionManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { loadModeConfig } from "pi-permission-modes/src/config-load.ts";
3
+ import type { ModeDef, PermissionModeConfig } from "pi-permission-modes/src/schema.ts";
4
+ import { TASK_TOOL_SCHEMA } from "./task-schema.js";
5
+ import { HUB_TOOL_SCHEMA, type HubCaller, type LiveAgentAdapter } from "./hub.js";
6
+ import { assembleChildPrompt, computeEffectiveTools, type ParentToolSnapshot } from "./policy.js";
7
+ import { createChildApprovalBridge, createPersistentChildSession } from "./session-factory.js";
8
+ import { brokeredChildPermission, ChildPermissionResolver, ParentApprovalBroker } from "./permission.js";
9
+ import {
10
+ ModelConfigStore,
11
+ ModelConfigurationService,
12
+ defaultGlobalModelConfigPath,
13
+ defaultProjectModelConfigPath,
14
+ resolveAgentModel,
15
+ type CatalogModel,
16
+ type ModelCatalog,
17
+ type ModelOverride,
18
+ type ModelThinking,
19
+ } from "./model-selection.js";
20
+ import {
21
+ GitIsolationAdapter,
22
+ WorkspaceLeaseManager,
23
+ createWorkspaceMutationGuard,
24
+ validateWorkspaceCwd,
25
+ validateWriteScope,
26
+ type IsolatedWorkspaceRecord,
27
+ type WorkspaceLease,
28
+ } from "./workspace.js";
29
+ import { PersistentAgentRuntime, registerPersistentAgentTools, type PersistentRuntimeExecutorInput } from "./runtime.js";
30
+ import { persistFullAgentOutput } from "./output-delivery.js";
31
+ import { createChildSandboxBash } from "./sandbox.js";
32
+ import { loadRoleProfiles, type RoleProfile } from "../roles.js";
33
+
34
+ const BUILTIN_CHILD_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
35
+ const DEFAULT_IDLE_TTL_MS = 420_000;
36
+
37
+ interface ParentState {
38
+ parentPath: string;
39
+ parentId: string;
40
+ context: ExtensionContext;
41
+ runtime: PersistentAgentRuntime;
42
+ approval: ParentApprovalBroker;
43
+ models: ModelConfigurationService;
44
+ leases: WorkspaceLeaseManager;
45
+ isolation: GitIsolationAdapter;
46
+ workspaces: Map<string, WorkspaceLease>;
47
+ childCwds: Map<string, string>;
48
+ isolated: Map<string, IsolatedWorkspaceRecord>;
49
+ controllers: Map<string, ProductionAgentController>;
50
+ parkTimers: Map<string, NodeJS.Timeout>;
51
+ }
52
+
53
+ function isRecord(value: unknown): value is Record<string, unknown> {
54
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
55
+ }
56
+
57
+ function assistantText(session: AgentSession): string {
58
+ for (const message of [...session.state.messages].reverse()) {
59
+ if (message.role !== "assistant") continue;
60
+ if (typeof message.content === "string") return message.content;
61
+ return message.content
62
+ .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
63
+ .map((part) => part.text)
64
+ .join("\n");
65
+ }
66
+ return "";
67
+ }
68
+
69
+ function currentMode(config: PermissionModeConfig, context: ExtensionContext): ModeDef {
70
+ let selected: string | undefined;
71
+ for (const entry of context.sessionManager.getEntries()) {
72
+ if (entry.type === "custom" && entry.customType === "perm-mode" && isRecord(entry.data) && typeof entry.data.mode === "string" && config.modes[entry.data.mode]) {
73
+ selected = entry.data.mode;
74
+ }
75
+ }
76
+ if (!selected && process.env.PI_PERMISSION_MODE && config.modes[process.env.PI_PERMISSION_MODE]) selected = process.env.PI_PERMISSION_MODE;
77
+ if (!selected && !context.hasUI) {
78
+ selected = config.cycleOrder.find((name) => config.modes[name]?.sandbox.enabled && !config.modes[name]?.sandbox.writable)
79
+ ?? config.cycleOrder.find((name) => config.modes[name]?.sandbox.enabled);
80
+ }
81
+ return config.modes[selected ?? config.defaultMode] ?? config.modes[config.defaultMode]!;
82
+ }
83
+
84
+ class ContextModelCatalog implements ModelCatalog {
85
+ constructor(private readonly context: ExtensionContext) {}
86
+
87
+ async resolve(canonical: string): Promise<CatalogModel | undefined> {
88
+ const slash = canonical.indexOf("/");
89
+ if (slash <= 0 || slash === canonical.length - 1) return undefined;
90
+ const provider = canonical.slice(0, slash);
91
+ const modelId = canonical.slice(slash + 1);
92
+ const model = this.context.modelRegistry.find(provider, modelId);
93
+ if (!model) return undefined;
94
+ return {
95
+ provider: model.provider,
96
+ model: model.id,
97
+ available: this.context.modelRegistry.getAvailable().some((candidate) => candidate.provider === model.provider && candidate.id === model.id),
98
+ authenticated: this.context.modelRegistry.hasConfiguredAuth(model),
99
+ };
100
+ }
101
+
102
+ async resolveParentFallback(): Promise<CatalogModel | undefined> {
103
+ const model = this.context.model;
104
+ return model ? await this.resolve(`${model.provider}/${model.id}`) : undefined;
105
+ }
106
+ }
107
+
108
+ function parseOverride(model: string | undefined): ModelOverride | undefined {
109
+ return model ? { model } : undefined;
110
+ }
111
+
112
+ class ProductionAgentController implements LiveAgentAdapter {
113
+ private session?: AgentSession;
114
+ private disposed = false;
115
+
116
+ constructor(
117
+ private readonly owner: PersistentAgentProduction,
118
+ private readonly state: ParentState,
119
+ readonly agentId: string,
120
+ private readonly manager: SessionManager,
121
+ ) {}
122
+
123
+ async runInitial(input: PersistentRuntimeExecutorInput): Promise<string> {
124
+ const prepared = await this.prepare(input);
125
+ const abort = () => { void this.abort("task cancellation"); };
126
+ input.context.signal.addEventListener("abort", abort, { once: true });
127
+ try {
128
+ await prepared.session.prompt(prepared.initialMessage, { expandPromptTemplates: false, source: "extension" });
129
+ await this.owner.finalizeWorkspace(this.state, this.agentId);
130
+ this.owner.schedulePark(this.state, this.agentId);
131
+ return assistantText(prepared.session);
132
+ } finally {
133
+ input.context.signal.removeEventListener("abort", abort);
134
+ }
135
+ }
136
+
137
+ async steer(message: string): Promise<void> {
138
+ if (!this.session) throw new Error(`${this.agentId}: live session is unavailable`);
139
+ await this.session.steer(message);
140
+ }
141
+
142
+ async sendUserMessage(message: string): Promise<void> {
143
+ if (this.disposed) throw new Error(`${this.agentId}: live controller is disposed`);
144
+ this.owner.clearPark(this.state, this.agentId);
145
+ setTimeout(() => {
146
+ void this.runHubTurn(message).catch(async (error) => {
147
+ const turnId = this.state.runtime.journal.getState().agents[this.agentId]?.currentTurnId;
148
+ if (turnId) await this.state.runtime.hub.settleMessageTurn(this.agentId, turnId, "failed", error instanceof Error ? error.message : String(error));
149
+ });
150
+ }, 0);
151
+ }
152
+
153
+ async abort(_reason: string): Promise<void> {
154
+ this.owner.clearPark(this.state, this.agentId);
155
+ await this.session?.abort();
156
+ }
157
+
158
+ dispose(): void {
159
+ this.disposed = true;
160
+ this.session?.dispose();
161
+ this.session = undefined;
162
+ }
163
+
164
+ private async runHubTurn(message: string): Promise<void> {
165
+ const prepared = await this.prepare();
166
+ let status: "completed" | "failed" = "completed";
167
+ let error: string | undefined;
168
+ try {
169
+ await prepared.session.sendUserMessage(message);
170
+ await persistFullAgentOutput(this.state.runtime.layout, this.agentId, assistantText(prepared.session));
171
+ await this.owner.finalizeWorkspace(this.state, this.agentId);
172
+ } catch (failure) {
173
+ status = "failed";
174
+ error = failure instanceof Error ? failure.message : String(failure);
175
+ }
176
+ const turnId = this.state.runtime.journal.getState().agents[this.agentId]?.currentTurnId;
177
+ if (turnId) await this.state.runtime.hub.settleMessageTurn(this.agentId, turnId, status, error);
178
+ this.owner.schedulePark(this.state, this.agentId);
179
+ }
180
+
181
+ private async prepare(input?: PersistentRuntimeExecutorInput): Promise<{ session: AgentSession; initialMessage: string }> {
182
+ this.session?.dispose();
183
+ const prepared = await this.owner.buildChildSession(this.state, this, this.manager, input);
184
+ this.session = prepared.session;
185
+ this.disposed = false;
186
+ return prepared;
187
+ }
188
+ }
189
+
190
+ export class PersistentAgentProduction {
191
+ private readonly parents = new Map<string, Promise<ParentState>>();
192
+ private activeParentPath?: string;
193
+
194
+ constructor(private readonly pi: ExtensionAPI) {}
195
+
196
+ register(): void {
197
+ registerPersistentAgentTools(this.pi, {
198
+ runtimeForContext: async (context) => (await this.parent(context)).runtime,
199
+ directModelCommand: async (args, context) => await this.directModel(args, context),
200
+ });
201
+ this.pi.on("session_start", (_event, context) => {
202
+ this.activeParentPath = context.sessionManager.getSessionFile();
203
+ });
204
+ this.pi.on("session_shutdown", async () => {
205
+ for (const pending of this.parents.values()) {
206
+ const state = await pending.catch(() => undefined);
207
+ if (!state) continue;
208
+ state.approval.shutdown();
209
+ for (const timer of state.parkTimers.values()) clearTimeout(timer);
210
+ for (const controller of state.controllers.values()) controller.dispose();
211
+ await state.runtime.shutdown();
212
+ }
213
+ this.parents.clear();
214
+ });
215
+ }
216
+
217
+ async buildChildSession(
218
+ state: ParentState,
219
+ controller: ProductionAgentController,
220
+ manager: SessionManager,
221
+ input?: PersistentRuntimeExecutorInput,
222
+ ): Promise<{ session: AgentSession; initialMessage: string }> {
223
+ const context = state.context;
224
+ const agent = state.runtime.journal.getState().agents[controller.agentId];
225
+ if (!agent) throw new Error(`${controller.agentId}: Agent registry record is missing`);
226
+ const roles = await loadRoleProfiles();
227
+ const role = roles.find((candidate) => candidate.selector === agent.selector);
228
+ if (!role) throw new Error(`${agent.selector}: role profile is unavailable`);
229
+ const workspace = input ? await this.ensureWorkspace(state, input) : state.workspaces.get(controller.agentId);
230
+ if (!workspace) throw new Error(`${controller.agentId}: workspace record is unavailable for revive`);
231
+ const childCwd = state.childCwds.get(controller.agentId) ?? workspace.root;
232
+
233
+ const nestedDefinitions = this.childToolDefinitions(state, input, role);
234
+ const parent: ParentToolSnapshot = {
235
+ active: this.pi.getActiveTools(),
236
+ definitions: new Map(nestedDefinitions.map((definition) => [definition.name, definition])),
237
+ };
238
+ const policy = computeEffectiveTools({
239
+ parent,
240
+ childLoadable: [...BUILTIN_CHILD_TOOLS, "task", "hub"],
241
+ childDefinitions: parent.definitions,
242
+ role,
243
+ callTools: input?.item.tools,
244
+ hardDenied: input ? [] : ["task"],
245
+ currentDepth: input?.depth ?? Number(agent.metadata?.depth ?? 0),
246
+ });
247
+ const modeConfig = loadModeConfig(workspace.root, getAgentDir(), (message) => context.ui.notify(message, "warning"));
248
+ const mode = currentMode(modeConfig, context);
249
+ const sandboxBash = policy.effectiveTools.includes("bash")
250
+ ? await createChildSandboxBash(mode.sandbox, childCwd)
251
+ : { reason: "bash-not-enabled" };
252
+ const effectivePolicy = sandboxBash.definition
253
+ ? { ...policy, customTools: [...policy.customTools.filter((tool) => tool.name !== "bash"), sandboxBash.definition] }
254
+ : policy;
255
+
256
+ const configs = await new ModelConfigStore({
257
+ globalPath: defaultGlobalModelConfigPath(),
258
+ projectPath: defaultProjectModelConfigPath(context.cwd),
259
+ }).load(context.isProjectTrusted());
260
+ const choice = await resolveAgentModel({
261
+ input: {
262
+ selector: role.selector,
263
+ agentId: controller.agentId,
264
+ oneShot: parseOverride(input?.item.model),
265
+ projectTrusted: context.isProjectTrusted(),
266
+ profile: parseOverride(role.model),
267
+ parentThinking: "medium",
268
+ },
269
+ journal: state.runtime.journal,
270
+ configs,
271
+ catalog: new ContextModelCatalog(context),
272
+ });
273
+ const model = context.modelRegistry.find(choice.provider, choice.model);
274
+ if (!model) throw new Error(`${choice.canonical}: resolved model disappeared before Agent turn start`);
275
+ const turnId = input?.turnId ?? state.runtime.journal.getState().agents[controller.agentId]?.currentTurnId;
276
+ if (turnId && state.runtime.journal.getState().turns[turnId]?.state === "running") {
277
+ await state.runtime.journal.append({
278
+ kind: "turn.audit",
279
+ agentId: controller.agentId,
280
+ jobId: input?.jobId,
281
+ turnId,
282
+ payload: {
283
+ selector: role.selector,
284
+ profileHash: role.profileHash,
285
+ sourceHash: role.sourceHash,
286
+ profileVersion: role.profileVersion,
287
+ runtimeAdapterVersion: role.runtimeAdapterVersion,
288
+ effectiveTools: effectivePolicy.effectiveTools,
289
+ unavailableTools: effectivePolicy.unavailable,
290
+ sandbox: mode.sandbox.enabled ? (sandboxBash.definition ? "enabled" : "unavailable") : "disabled",
291
+ sandboxReason: sandboxBash.reason,
292
+ provider: choice.provider,
293
+ model: choice.model,
294
+ modelLayer: choice.layer,
295
+ thinking: choice.thinking,
296
+ oneShot: choice.oneShot,
297
+ persistent: choice.persistent,
298
+ },
299
+ });
300
+ }
301
+
302
+ const resolver = new ChildPermissionResolver({ mode, cwd: childCwd, sandboxExecutorAvailable: Boolean(sandboxBash.definition) });
303
+ const permission = brokeredChildPermission(resolver, state.approval, {
304
+ agentId: controller.agentId,
305
+ jobId: input?.jobId ?? `hub-${controller.agentId}`,
306
+ signal: input?.context.signal,
307
+ });
308
+ const approval = createChildApprovalBridge({
309
+ agentId: controller.agentId,
310
+ jobId: input?.jobId,
311
+ cwd: childCwd,
312
+ decide: permission.decide,
313
+ requestApproval: permission.requestApproval,
314
+ });
315
+ const prompt = assembleChildPrompt({
316
+ runtimeEnvelope: [
317
+ "Official Pi persistent Agent runtime. The parent conversation is not copied.",
318
+ `Agent ID: ${controller.agentId}`,
319
+ `Model: ${choice.canonical} (${choice.layer}, thinking=${choice.thinking})`,
320
+ `Unavailable requested tools: ${policy.unavailable.map((item) => `${item.name}:${item.reason}`).join(", ") || "none"}`,
321
+ ].join("\n"),
322
+ role,
323
+ task: input?.item.task ?? "Continue this persistent Agent from the explicit hub message.",
324
+ context: input?.item.context,
325
+ cwd: childCwd,
326
+ workspace: { mode: workspace.mode, root: workspace.root },
327
+ });
328
+ return await createPersistentChildSession({
329
+ cwd: childCwd,
330
+ agentDir: getAgentDir(),
331
+ projectTrusted: context.isProjectTrusted(),
332
+ sessionManager: manager,
333
+ prompt,
334
+ policy: effectivePolicy,
335
+ childExtensions: [
336
+ { name: "aili-child-approval", factory: approval },
337
+ { name: "aili-child-workspace", factory: createWorkspaceMutationGuard(state.leases, controller.agentId) },
338
+ ],
339
+ topLevelExtensionNames: ["aili-runtime", "aili-top-coordinator"],
340
+ model: model as CreateAgentSessionOptions["model"],
341
+ thinkingLevel: choice.thinking as CreateAgentSessionOptions["thinkingLevel"],
342
+ });
343
+ }
344
+
345
+ clearPark(state: ParentState, agentId: string): void {
346
+ const timer = state.parkTimers.get(agentId);
347
+ if (timer) clearTimeout(timer);
348
+ state.parkTimers.delete(agentId);
349
+ }
350
+
351
+ schedulePark(state: ParentState, agentId: string, ttlMs = DEFAULT_IDLE_TTL_MS): void {
352
+ this.clearPark(state, agentId);
353
+ if (ttlMs <= 0) return;
354
+ const timer = setTimeout(() => {
355
+ void (async () => {
356
+ if (await state.runtime.hub.park(agentId)) state.controllers.delete(agentId);
357
+ })();
358
+ }, ttlMs);
359
+ timer.unref?.();
360
+ state.parkTimers.set(agentId, timer);
361
+ }
362
+
363
+ async finalizeWorkspace(state: ParentState, agentId: string): Promise<void> {
364
+ const record = state.isolated.get(agentId);
365
+ if (!record) return;
366
+ state.isolated.set(agentId, await state.isolation.finalize(record));
367
+ }
368
+
369
+ private async parent(context: ExtensionContext): Promise<ParentState> {
370
+ const parentPath = context.sessionManager.getSessionFile();
371
+ if (!parentPath) throw new Error("persistent Agents require a durable parent Pi Session JSONL; save/start the parent session first");
372
+ this.activeParentPath = parentPath;
373
+ let pending = this.parents.get(parentPath);
374
+ if (!pending) {
375
+ pending = this.createParent(context, parentPath);
376
+ this.parents.set(parentPath, pending);
377
+ }
378
+ const state = await pending;
379
+ state.context = context;
380
+ return state;
381
+ }
382
+
383
+ private async createParent(context: ExtensionContext, parentPath: string): Promise<ParentState> {
384
+ const parentId = context.sessionManager.getSessionId();
385
+ let state!: ParentState;
386
+ const approval = new ParentApprovalBroker({
387
+ get hasUI() { return state?.context.hasUI ?? context.hasUI; },
388
+ ask: async (packet) => {
389
+ const active = state?.context ?? context;
390
+ if (!active.hasUI) return "dismiss";
391
+ const choice = await active.ui.select("AILI Agent tool approval", ["Allow once", "Deny"], { signal: active.signal });
392
+ return choice === "Allow once" ? "allow" : choice === "Deny" ? "deny" : "dismiss";
393
+ },
394
+ });
395
+ let modelService!: ModelConfigurationService;
396
+ const runtime = await PersistentAgentRuntime.create({
397
+ parentSessionPath: parentPath,
398
+ parentId,
399
+ cwd: context.cwd,
400
+ execute: async (input) => {
401
+ const controller = new ProductionAgentController(this, state, input.agentId, input.sessionManager);
402
+ state.controllers.set(input.agentId, controller);
403
+ state.runtime.hub.registerLive(input.agentId, controller);
404
+ try {
405
+ return { output: await controller.runInitial(input) };
406
+ } catch (error) {
407
+ return { status: "failed", output: "", error: error instanceof Error ? error.message : String(error) };
408
+ }
409
+ },
410
+ parentDelivery: {
411
+ scanDeliveryIds: async () => new Set((state?.context ?? context).sessionManager.getEntries()
412
+ .filter((entry) => entry.type === "custom_message" && isRecord(entry.details) && typeof entry.details.deliveryId === "string")
413
+ .map((entry) => (entry as { details: { deliveryId: string } }).details.deliveryId)),
414
+ send: async (message) => {
415
+ if (this.activeParentPath !== parentPath) return "unavailable";
416
+ this.pi.sendMessage({ customType: message.customType, content: message.content, display: message.display, details: message.details }, { triggerTurn: true, deliverAs: "nextTurn" });
417
+ return "sent";
418
+ },
419
+ },
420
+ revive: async (agentId, manager) => {
421
+ const controller = new ProductionAgentController(this, state, agentId, manager);
422
+ await this.buildChildSession(state, controller, manager);
423
+ state.controllers.set(agentId, controller);
424
+ return controller;
425
+ },
426
+ modelHubOperation: async (request, caller) => await this.modelHub(state, modelService, request, caller),
427
+ onRelease: async (agentId) => await this.releaseAgent(state, agentId),
428
+ });
429
+ const store = new ModelConfigStore({
430
+ globalPath: defaultGlobalModelConfigPath(),
431
+ projectPath: defaultProjectModelConfigPath(context.cwd),
432
+ });
433
+ modelService = new ModelConfigurationService(store, runtime.journal, async (override) => {
434
+ const resolved = await new ContextModelCatalog(state.context).resolve(override.model);
435
+ if (!resolved?.available || !resolved.authenticated) throw new Error(`${override.model}: model is unavailable or unauthenticated`);
436
+ });
437
+ state = {
438
+ parentPath,
439
+ parentId,
440
+ context,
441
+ runtime,
442
+ approval,
443
+ models: modelService,
444
+ leases: new WorkspaceLeaseManager(),
445
+ isolation: new GitIsolationAdapter(runtime.layout, runtime.journal),
446
+ workspaces: new Map(),
447
+ childCwds: new Map(),
448
+ isolated: new Map(),
449
+ controllers: new Map(),
450
+ parkTimers: new Map(),
451
+ };
452
+ return state;
453
+ }
454
+
455
+ private childToolDefinitions(state: ParentState, input: PersistentRuntimeExecutorInput | undefined, role: RoleProfile): ToolDefinition[] {
456
+ const task: ToolDefinition = {
457
+ name: "task",
458
+ label: "Task",
459
+ description: "Create a nested persistent Agent synchronously within the explicit spawn/depth policy. Use the public async field if supplied; never send profile-only blocking metadata.",
460
+ parameters: TASK_TOOL_SCHEMA,
461
+ execute: async (_id, params) => {
462
+ if (!input) throw new Error("nested task is unavailable outside an inherited scheduled turn");
463
+ const result = await state.runtime.task.submit(params, {
464
+ parentAgentId: input.agentId,
465
+ parentSelector: role.selector,
466
+ parentDepth: input.depth,
467
+ inheritedPermit: input.context.permit,
468
+ });
469
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
470
+ },
471
+ };
472
+ const hub: ToolDefinition = {
473
+ name: "hub",
474
+ label: "Hub",
475
+ description: "Inspect or message this Agent and its descendants.",
476
+ parameters: HUB_TOOL_SCHEMA,
477
+ execute: async (_id, params) => {
478
+ const result = await state.runtime.hub.execute(params, { agentId: input?.agentId });
479
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
480
+ },
481
+ };
482
+ return [task, hub];
483
+ }
484
+
485
+ private async ensureWorkspace(state: ParentState, input: PersistentRuntimeExecutorInput): Promise<WorkspaceLease> {
486
+ const existing = state.workspaces.get(input.agentId);
487
+ if (existing) return existing;
488
+ const projectRoot = state.context.cwd;
489
+ const scope = await validateWriteScope(projectRoot, input.item.writeScope);
490
+ const decision = state.leases.decide(input.agentId, input.item.workspace, projectRoot, scope);
491
+ let root = projectRoot;
492
+ if (decision.mode === "isolated") {
493
+ const record = await state.isolation.create(input.agentId, projectRoot);
494
+ state.isolated.set(input.agentId, record);
495
+ root = record.root;
496
+ }
497
+ const cwd = await validateWorkspaceCwd(root, input.item.cwd);
498
+ const lease: WorkspaceLease = {
499
+ agentId: input.agentId,
500
+ mode: decision.mode,
501
+ projectRoot,
502
+ root,
503
+ scope,
504
+ acquiredAt: new Date().toISOString(),
505
+ };
506
+ state.leases.acquire(lease);
507
+ state.workspaces.set(input.agentId, lease);
508
+ state.childCwds.set(input.agentId, cwd);
509
+ return lease;
510
+ }
511
+
512
+ private async releaseAgent(state: ParentState, agentId: string): Promise<void> {
513
+ this.clearPark(state, agentId);
514
+ state.controllers.get(agentId)?.dispose();
515
+ state.controllers.delete(agentId);
516
+ const isolated = state.isolated.get(agentId);
517
+ if (isolated) {
518
+ const finalized = isolated.status === "active" ? await state.isolation.finalize(isolated) : isolated;
519
+ await state.isolation.cleanup(finalized);
520
+ state.isolated.delete(agentId);
521
+ }
522
+ state.leases.release(agentId);
523
+ state.workspaces.delete(agentId);
524
+ state.childCwds.delete(agentId);
525
+ }
526
+
527
+ private async modelHub(state: ParentState, service: ModelConfigurationService, request: Record<string, unknown>, caller: HubCaller): Promise<unknown> {
528
+ const operation = String(request.operation ?? "");
529
+ const agentId = typeof request.agentId === "string" ? request.agentId : undefined;
530
+ const selector = typeof request.selector === "string" ? request.selector : undefined;
531
+ if (Boolean(agentId) === Boolean(selector)) throw new Error("hub model requires exactly one of agentId or selector");
532
+ if (caller.agentId && agentId !== caller.agentId) throw new Error("child Agent may request model changes only for itself");
533
+ if (operation === "query") {
534
+ if (agentId) return { agentId, override: state.runtime.journal.getState().models[agentId] ?? null };
535
+ const configs = await new ModelConfigStore({ globalPath: defaultGlobalModelConfigPath(), projectPath: defaultProjectModelConfigPath(state.context.cwd) }).load(state.context.isProjectTrusted());
536
+ return { selector, global: configs.global.roles[selector!] ?? null, project: configs.project?.roles[selector!] ?? null, diagnostics: configs.diagnostics };
537
+ }
538
+ if (operation !== "request" && operation !== "clear") throw new Error(`unsupported hub model operation: ${operation}`);
539
+ const override = operation === "clear" ? undefined : parseOverride(typeof request.model === "string" ? request.model : undefined);
540
+ if (operation === "request" && !override) throw new Error("hub model request requires model");
541
+ const confirmation = {
542
+ hasUI: state.context.hasUI,
543
+ confirm: async (packet: { scope: "instance" | "global" | "project"; target: string; oldValue?: ModelOverride; newValue?: ModelOverride }) => {
544
+ if (!state.context.hasUI) return "dismiss" as const;
545
+ const allowed = await state.context.ui.confirm("AILI Agent model change", `scope=${packet.scope}\ntarget=${packet.target}\nold=${packet.oldValue?.model ?? "none"}\nnew=${packet.newValue?.model ?? "none"}`, { signal: state.context.signal });
546
+ return allowed ? "confirm" as const : "deny" as const;
547
+ },
548
+ };
549
+ return agentId
550
+ ? await service.requestInstanceChange(agentId, override, confirmation)
551
+ : await service.requestRoleChange("global", selector!, override, state.context.isProjectTrusted(), confirmation);
552
+ }
553
+
554
+ private async directModel(args: string, context: ExtensionContext): Promise<string> {
555
+ const [scope, target, model, thinking] = args.trim().split(/\s+/);
556
+ if (!scope || !target || !model || !["global", "project", "instance"].includes(scope)) {
557
+ throw new Error("usage: /aili-agent-model <global|project|instance> <selector|agent-id> <provider/model|clear> [thinking]");
558
+ }
559
+ const state = await this.parent(context);
560
+ const override = model === "clear" ? undefined : { model, ...(thinking ? { thinking: thinking as ModelThinking } : {}) };
561
+ if (scope === "instance") await state.models.userSetInstance(target, override);
562
+ else await state.models.userSetRole(scope as "global" | "project", target, override, context.isProjectTrusted());
563
+ return `${scope} model override ${override ? `set to ${override.model}` : "cleared"} for ${target}`;
564
+ }
565
+ }
566
+
567
+ export function registerPersistentAgentRuntime(pi: ExtensionAPI): void {
568
+ new PersistentAgentProduction(pi).register();
569
+ }