@gotgenes/pi-subagents 11.3.0 → 11.5.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.
@@ -26,6 +26,7 @@ import type { ExecutionState } from "#src/lifecycle/execution-state";
26
26
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
27
27
  import type { LifetimeUsage } from "#src/lifecycle/usage";
28
28
  import { addUsage } from "#src/lifecycle/usage";
29
+ import type { Workspace, WorkspaceProvider } from "#src/lifecycle/workspace";
29
30
  import type { WorktreeIsolation } from "#src/lifecycle/worktree-isolation";
30
31
  import { NotificationState } from "#src/observation/notification-state";
31
32
  import { subscribeAgentObserver } from "#src/observation/record-observer";
@@ -72,6 +73,10 @@ export interface AgentInit {
72
73
  worktree?: WorktreeIsolation;
73
74
  observer?: AgentLifecycleObserver;
74
75
  getRunConfig?: () => RunConfig;
76
+ /** Resolves the registered workspace provider (if any) at run-start. */
77
+ getWorkspaceProvider?: () => WorkspaceProvider | undefined;
78
+ /** Parent working directory handed to a workspace provider's prepare(). */
79
+ baseCwd?: string;
75
80
 
76
81
  // Run config (required for run(), optional for tests)
77
82
  snapshot?: ParentSnapshot;
@@ -129,6 +134,10 @@ export class Agent {
129
134
  readonly worktree?: WorktreeIsolation;
130
135
  readonly observer?: AgentLifecycleObserver;
131
136
  private readonly _getRunConfig?: () => RunConfig;
137
+ private readonly _getWorkspaceProvider?: () => WorkspaceProvider | undefined;
138
+ private readonly _baseCwd: string;
139
+ /** Workspace prepared at run-start by a provider — undefined when none is registered. */
140
+ private _workspace?: Workspace;
132
141
 
133
142
  // Run config — optional (required for run())
134
143
  private readonly _snapshot?: ParentSnapshot;
@@ -186,6 +195,8 @@ export class Agent {
186
195
  this.worktree = init.worktree;
187
196
  this.observer = init.observer;
188
197
  this._getRunConfig = init.getRunConfig;
198
+ this._getWorkspaceProvider = init.getWorkspaceProvider;
199
+ this._baseCwd = init.baseCwd ?? "";
189
200
 
190
201
  // Run config
191
202
  this._snapshot = init.snapshot;
@@ -223,8 +234,23 @@ export class Agent {
223
234
  this.observer?.onStarted?.(this);
224
235
  this.wireSignal(this._signal, () => this.abort());
225
236
 
237
+ let cwd: string | undefined;
226
238
  try {
227
- this.worktree?.setup();
239
+ // Provider-first: a registered workspace provider supplies the cwd and
240
+ // owns teardown; otherwise fall back to the legacy worktree collaborator.
241
+ const provider = this._getWorkspaceProvider?.();
242
+ if (provider) {
243
+ this._workspace = await provider.prepare({
244
+ agentId: this.id,
245
+ agentType: this.type,
246
+ baseCwd: this._baseCwd,
247
+ invocation: this.invocation,
248
+ });
249
+ cwd = this._workspace?.cwd;
250
+ } else {
251
+ this.worktree?.setup();
252
+ cwd = this.worktree?.path;
253
+ }
228
254
  } catch (err) {
229
255
  this.markError(err);
230
256
  this.releaseListeners();
@@ -236,7 +262,7 @@ export class Agent {
236
262
  try {
237
263
  const result = await this._runner.run(this._snapshot, this.type, this._prompt, {
238
264
  context: {
239
- cwd: this.worktree?.path,
265
+ cwd,
240
266
  parentSession: this._parentSession,
241
267
  },
242
268
  model: this._model,
@@ -442,9 +468,19 @@ export class Agent {
442
468
  this.releaseListeners();
443
469
 
444
470
  let finalResult = result.responseText;
445
- const wtResult = this.worktree?.cleanup(this.description);
446
- if (wtResult?.hasChanges && wtResult.branch) {
447
- finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
471
+ if (this._workspace) {
472
+ const finalStatus: AgentStatus = result.aborted
473
+ ? "aborted"
474
+ : result.steered
475
+ ? "steered"
476
+ : "completed";
477
+ const disposeResult = this._workspace.dispose({ status: finalStatus, description: this.description });
478
+ if (disposeResult?.resultAddendum) finalResult += disposeResult.resultAddendum;
479
+ } else {
480
+ const wtResult = this.worktree?.cleanup(this.description);
481
+ if (wtResult?.hasChanges && wtResult.branch) {
482
+ finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
483
+ }
448
484
  }
449
485
 
450
486
  if (result.aborted) this.markAborted(finalResult);
@@ -465,8 +501,9 @@ export class Agent {
465
501
  this.releaseListeners();
466
502
 
467
503
  try {
468
- this.worktree?.cleanup(this.description);
469
- } catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
504
+ if (this._workspace) this._workspace.dispose({ status: "error", description: this.description });
505
+ else this.worktree?.cleanup(this.description);
506
+ } catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
470
507
 
471
508
  this.observer?.onRunFinished?.(this);
472
509
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * child-lifecycle.ts — Child-execution lifecycle event contract and publisher.
3
+ *
4
+ * The core publishes its child-execution lifecycle as ordered events on the Pi
5
+ * event bus; reactive consumers (permissions, telemetry, UI) subscribe rather
6
+ * than the core reaching out to them (ADR 0002). This module owns the channel
7
+ * names, payload shapes, and the publisher that emits them.
8
+ *
9
+ * The publisher takes an injected `emit` callback so this module stays free of
10
+ * Pi SDK imports — `index.ts` wires it to `pi.events.emit`.
11
+ */
12
+
13
+ /** Emitted at the start of a child run, before the session is created. */
14
+ export const SUBAGENT_CHILD_SPAWNING = "subagents:child:spawning";
15
+
16
+ /**
17
+ * Emitted after the child session is created, immediately before
18
+ * `bindExtensions()`. Carries the child identity consumers need to register
19
+ * the session. Subscribers must register synchronously so the entry lands
20
+ * before binding proceeds (see ADR 0002 / the event-bus synchronous-dispatch
21
+ * guarantee).
22
+ */
23
+ export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
24
+
25
+ /** Emitted after the child's prompt resolves (normal, steered, or aborted). */
26
+ export const SUBAGENT_CHILD_COMPLETED = "subagents:child:completed";
27
+
28
+ /** Emitted in the run's `finally` — always fires, on success and error. */
29
+ export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
30
+
31
+ /** Payload for `subagents:child:spawning`. */
32
+ export interface ChildSpawningEvent {
33
+ agentName: string;
34
+ parentSessionId?: string;
35
+ }
36
+
37
+ /** Payload for `subagents:child:session-created`. */
38
+ export interface ChildSessionCreatedEvent {
39
+ /** Child session directory — the registry key. */
40
+ sessionDir: string;
41
+ agentName: string;
42
+ parentSessionId?: string;
43
+ }
44
+
45
+ /** Payload for `subagents:child:completed`. */
46
+ export interface ChildCompletedEvent {
47
+ sessionDir: string;
48
+ agentName: string;
49
+ /** True if the run was hard-aborted (max turns + grace exceeded). */
50
+ aborted: boolean;
51
+ /** True if the run was steered to wrap up (soft turn limit) but finished. */
52
+ steered: boolean;
53
+ }
54
+
55
+ /** Payload for `subagents:child:disposed`. */
56
+ export interface ChildDisposedEvent {
57
+ sessionDir: string;
58
+ }
59
+
60
+ /** Narrow emit seam — injected, never imports the Pi SDK. */
61
+ export type LifecycleEmit = (channel: string, data: unknown) => void;
62
+
63
+ /** Publishes the child-execution lifecycle on the event bus. */
64
+ export interface ChildLifecyclePublisher {
65
+ spawning(event: ChildSpawningEvent): void;
66
+ sessionCreated(event: ChildSessionCreatedEvent): void;
67
+ completed(event: ChildCompletedEvent): void;
68
+ disposed(event: ChildDisposedEvent): void;
69
+ }
70
+
71
+ /** Build a publisher backed by an injected `emit` callback. */
72
+ export function createChildLifecyclePublisher(
73
+ emit: LifecycleEmit,
74
+ ): ChildLifecyclePublisher {
75
+ return {
76
+ spawning(event) {
77
+ emit(SUBAGENT_CHILD_SPAWNING, event);
78
+ },
79
+ sessionCreated(event) {
80
+ emit(SUBAGENT_CHILD_SESSION_CREATED, event);
81
+ },
82
+ completed(event) {
83
+ emit(SUBAGENT_CHILD_COMPLETED, event);
84
+ },
85
+ disposed(event) {
86
+ emit(SUBAGENT_CHILD_DISPOSED, event);
87
+ },
88
+ };
89
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * workspace.ts — The single generative extension seam (ADR 0002, Phase 16 Step 2).
3
+ *
4
+ * "Where does a child run, and what brackets the run?" is a strategy (git
5
+ * worktree, container, tmpdir, remote sandbox), not core behavior. The core
6
+ * needs only a working directory plus a disposal hook; the default — the
7
+ * parent's cwd, with no setup/teardown — is always correct.
8
+ *
9
+ * Unlike the observational lifecycle events in child-lifecycle.ts, this is a
10
+ * *generative* seam: a registered provider returns a value the core consumes
11
+ * synchronously at run-start. The core has no knowledge of git or worktrees.
12
+ */
13
+
14
+ import type { AgentStatus } from "#src/lifecycle/agent";
15
+ import type { AgentInvocation, SubagentType } from "#src/types";
16
+
17
+ /** Context the core hands a provider when a child run starts. */
18
+ export interface WorkspacePrepareContext {
19
+ agentId: string;
20
+ agentType: SubagentType;
21
+ baseCwd: string;
22
+ invocation?: AgentInvocation;
23
+ }
24
+
25
+ /** Outcome the core reports to a workspace when the run ends. */
26
+ export interface WorkspaceDisposeOutcome {
27
+ status: AgentStatus;
28
+ description: string;
29
+ }
30
+
31
+ /** What dispose may hand back for the core to fold into the child result. */
32
+ export interface WorkspaceDisposeResult {
33
+ /** Appended verbatim to the child's result text — the provider owns the wording. */
34
+ resultAddendum?: string;
35
+ }
36
+
37
+ /** A prepared working directory plus its bracketed teardown. Born complete. */
38
+ export interface Workspace {
39
+ /** The working directory — already exists when the workspace is handed back. */
40
+ readonly cwd: string;
41
+ dispose(outcome: WorkspaceDisposeOutcome): WorkspaceDisposeResult | undefined;
42
+ }
43
+
44
+ /** The single generative seam: supplies a child's workspace. */
45
+ export interface WorkspaceProvider {
46
+ prepare(ctx: WorkspacePrepareContext): Promise<Workspace | undefined>;
47
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
9
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
9
10
  import type { SpawnOptions, SubagentRecord, SubagentsService } from "#src/service/service";
10
11
  import type { ModelRegistry } from "#src/session/model-resolver";
11
12
  import type { Agent, SessionContext } from "#src/types";
@@ -18,6 +19,7 @@ export interface AgentManagerLike {
18
19
  abort(id: string): boolean;
19
20
  waitForAll(): Promise<void>;
20
21
  hasRunning(): boolean;
22
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
21
23
  }
22
24
 
23
25
  /**
@@ -107,6 +109,10 @@ export class SubagentsServiceAdapter implements SubagentsService {
107
109
  hasRunning(): boolean {
108
110
  return this.manager.hasRunning();
109
111
  }
112
+
113
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
114
+ return this.manager.registerWorkspaceProvider(provider);
115
+ }
110
116
  }
111
117
 
112
118
  /**
@@ -10,8 +10,13 @@
10
10
  */
11
11
 
12
12
  import type { LifetimeUsage } from "#src/lifecycle/usage";
13
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
13
14
 
14
- export type { LifetimeUsage };
15
+ // Generative extension seam (ADR 0002, Phase 16 Step 2). Only the provider
16
+ // entry-point type is re-exported here; a consumer assigning to
17
+ // `WorkspaceProvider` gets `Workspace` and the context types via inference.
18
+ // The worktrees package (#263) adds named re-exports when it imports them.
19
+ export type { LifetimeUsage, WorkspaceProvider };
15
20
 
16
21
  export type SubagentStatus =
17
22
  | "queued"
@@ -73,6 +78,13 @@ export interface SubagentsService {
73
78
 
74
79
  /** Whether any agents are running or queued. */
75
80
  hasRunning(): boolean;
81
+
82
+ /**
83
+ * Register the single workspace provider that supplies a child's working
84
+ * directory plus bracketed setup/teardown. Throws if one is already
85
+ * registered. Returns a disposer that unregisters the provider.
86
+ */
87
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
76
88
  }
77
89
 
78
90
  /** Event channel constants for pi.events subscriptions. */
@@ -1,63 +0,0 @@
1
- /**
2
- * permission-bridge.ts — Cross-extension bridge to @gotgenes/pi-permission-system.
3
- *
4
- * pi-subagents does not import pi-permission-system directly. Instead it
5
- * accesses the published PermissionsService via a process-global Symbol.for()
6
- * key, the same mechanism pi-permission-system uses to publish itself.
7
- *
8
- * When pi-permission-system is not installed, getPermissionsService() returns
9
- * undefined and all registration calls are silent no-ops.
10
- */
11
-
12
- /**
13
- * The two PermissionsService methods pi-subagents needs.
14
- *
15
- * Follows ISP — does not expose the full PermissionsService surface
16
- * (checkPermission, getToolPermission, etc.) to avoid coupling.
17
- */
18
- interface PermissionsServiceConsumer {
19
- registerSubagentSession(
20
- sessionKey: string,
21
- info: { parentSessionId?: string; agentName: string },
22
- ): void;
23
- unregisterSubagentSession(sessionKey: string): void;
24
- }
25
-
26
- const PERMISSION_SERVICE_KEY = Symbol.for(
27
- "@gotgenes/pi-permission-system:service",
28
- );
29
-
30
- function getPermissionsService(): PermissionsServiceConsumer | undefined {
31
- return (globalThis as Record<symbol, unknown>)[
32
- PERMISSION_SERVICE_KEY
33
- ] as PermissionsServiceConsumer | undefined;
34
- }
35
-
36
- /**
37
- * Register a child session with pi-permission-system's SubagentSessionRegistry.
38
- *
39
- * Must be called after deriving sessionDir but before session.bindExtensions()
40
- * so isSubagentExecutionContext() hits the registry on the first check during
41
- * child extension initialization.
42
- *
43
- * @param sessionKey - The session directory path (unique per session).
44
- * @param info - Agent name and optional parent session ID for forwarding.
45
- */
46
- export function registerChildSession(
47
- sessionKey: string,
48
- info: { parentSessionId?: string; agentName: string },
49
- ): void {
50
- getPermissionsService()?.registerSubagentSession(sessionKey, info);
51
- }
52
-
53
- /**
54
- * Unregister a child session from pi-permission-system's SubagentSessionRegistry.
55
- *
56
- * Must be called in a finally block so cleanup happens on both success and
57
- * error paths.
58
- *
59
- * @param sessionKey - The session directory path used during registration.
60
- */
61
- export function unregisterChildSession(sessionKey: string): void {
62
- getPermissionsService()?.unregisterSubagentSession(sessionKey);
63
- }