@nt-ai-lab/deterministic-agent-workflow-pi 0.5.5 → 0.6.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.
@@ -0,0 +1,22 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import type { PiWorkflowIdleContext } from '../../../platform/domain/pi-workflow-extension-types.js';
3
+ /** @riviere-role cli-entrypoint */
4
+ export declare function registerPiWorkflowAutomation<TState extends {
5
+ readonly currentStateMachineState: string;
6
+ }>(pi: ExtensionAPI, options: {
7
+ readonly databasePath: string;
8
+ readonly automation?: {
9
+ readonly ownsState: (state: TState) => boolean;
10
+ readonly onIdle: (context: PiWorkflowIdleContext<TState>) => Promise<void>;
11
+ };
12
+ readonly isReady: (ctx: ExtensionContext) => boolean;
13
+ readonly getState: (ctx: ExtensionContext) => TState;
14
+ readonly runOperation: (ctx: ExtensionContext, args: readonly string[]) => {
15
+ readonly exitCode: number;
16
+ readonly output: string;
17
+ };
18
+ readonly fail: (ctx: ExtensionContext, reason: string) => void;
19
+ }): {
20
+ ownsState: (ctx: ExtensionContext) => boolean;
21
+ afterOperation: (ctx: ExtensionContext) => void;
22
+ };
@@ -0,0 +1,160 @@
1
+ import { ReviewCoordinator } from '@nt-ai-lab/deterministic-agent-workflow-cli';
2
+ import { WorkflowStateError } from '@nt-ai-lab/deterministic-agent-workflow-engine';
3
+ import { createStore } from '@nt-ai-lab/deterministic-agent-workflow-event-store';
4
+ import { createPiExtensionContextWindow } from '../../../platform/infra/external-clients/pi/pi-extension-context-window.js';
5
+ /** @riviere-role cli-entrypoint */
6
+ export function registerPiWorkflowAutomation(pi, options) {
7
+ const automation = options.automation;
8
+ if (automation === undefined)
9
+ return {
10
+ ownsState: () => false,
11
+ afterOperation: () => undefined,
12
+ };
13
+ const active = new Map();
14
+ const cancellations = new Map();
15
+ const refreshContext = createPiExtensionContextWindow(pi);
16
+ const fail = (ctx, error) => {
17
+ options.fail(ctx, `Workflow automation failed: ${String(error)}`);
18
+ };
19
+ const isReady = (ctx) => {
20
+ try {
21
+ return options.isReady(ctx);
22
+ }
23
+ catch (error) {
24
+ fail(ctx, error);
25
+ return false;
26
+ }
27
+ };
28
+ const ownsState = (ctx) => {
29
+ if (!isReady(ctx))
30
+ return false;
31
+ try {
32
+ return active.has(ctx.sessionManager.getSessionId()) || automation.ownsState(options.getState(ctx));
33
+ }
34
+ catch (error) {
35
+ fail(ctx, error);
36
+ return true;
37
+ }
38
+ };
39
+ const runIdle = async (ctx) => {
40
+ const sessionId = ctx.sessionManager.getSessionId();
41
+ if (!isReady(ctx) || !ctx.isIdle() || active.has(sessionId) || !ownsState(ctx))
42
+ return;
43
+ const controller = new AbortController();
44
+ active.set(sessionId, controller);
45
+ const stops = new Set();
46
+ cancellations.set(sessionId, stops);
47
+ const requireActive = () => {
48
+ if (controller.signal.aborted || active.get(sessionId) !== controller ||
49
+ ctx.sessionManager.getSessionId() !== sessionId || !isReady(ctx)) {
50
+ throw new WorkflowStateError('The workflow automation context is no longer active.');
51
+ }
52
+ };
53
+ const resume = {};
54
+ try {
55
+ await automation.onIdle({
56
+ sessionId,
57
+ signal: controller.signal,
58
+ workingDirectory: ctx.cwd,
59
+ getState: () => {
60
+ requireActive();
61
+ return options.getState(ctx);
62
+ },
63
+ runOperation: (operation, ...args) => {
64
+ requireActive();
65
+ const result = options.runOperation(ctx, [operation, ...args]);
66
+ if (result.exitCode !== 0)
67
+ throw new WorkflowStateError(result.output);
68
+ return result.output;
69
+ },
70
+ runReviews: async (request, client) => {
71
+ requireActive();
72
+ const store = createStore(options.databasePath);
73
+ const coordinator = new ReviewCoordinator({
74
+ store,
75
+ client,
76
+ now: () => new Date().toISOString()
77
+ });
78
+ const cancel = async () => {
79
+ const result = await coordinator.cancel(request.bundleId, 'Pi session shutdown');
80
+ if (result.type === 'failed')
81
+ throw new WorkflowStateError(result.reason);
82
+ };
83
+ stops.add(cancel);
84
+ try {
85
+ return await coordinator.run({
86
+ ...request,
87
+ sessionId,
88
+ workingDirectory: ctx.cwd
89
+ }, options.getState(ctx).currentStateMachineState);
90
+ }
91
+ finally {
92
+ stops.delete(cancel);
93
+ store.db.close();
94
+ }
95
+ },
96
+ resumeWithFreshContext: (instructions) => {
97
+ requireActive();
98
+ if (resume.instructions !== undefined)
99
+ throw new WorkflowStateError('Workflow automation already requested a fresh context.');
100
+ if (automation.ownsState(options.getState(ctx))) {
101
+ throw new WorkflowStateError('Cannot resume the conversational agent in a workflow-owned state.');
102
+ }
103
+ refreshContext(ctx, instructions);
104
+ resume.instructions = instructions;
105
+ },
106
+ });
107
+ }
108
+ catch (error) {
109
+ fail(ctx, error);
110
+ return;
111
+ }
112
+ finally {
113
+ active.delete(sessionId);
114
+ cancellations.delete(sessionId);
115
+ }
116
+ if (!controller.signal.aborted && resume.instructions !== undefined) {
117
+ pi.sendUserMessage(resume.instructions);
118
+ }
119
+ };
120
+ pi.on('session_shutdown', async (_event, ctx) => {
121
+ const sessionId = ctx.sessionManager.getSessionId();
122
+ active.get(sessionId)?.abort();
123
+ const results = await Promise.allSettled([...cancellations.get(sessionId) ?? []].map((cancel) => cancel()));
124
+ for (const result of results) {
125
+ if (result.status === 'rejected')
126
+ fail(ctx, result.reason);
127
+ }
128
+ });
129
+ pi.on('agent_settled', (_event, ctx) => runIdle(ctx));
130
+ pi.on('session_start', (_event, ctx) => runIdle(ctx));
131
+ pi.on('tool_call', (_event, ctx) => {
132
+ if (ownsState(ctx))
133
+ return {
134
+ block: true,
135
+ reason: 'The workflow owns this state; conversational tools are disabled.'
136
+ };
137
+ return undefined;
138
+ });
139
+ pi.on('input', (_event, ctx) => {
140
+ if (ownsState(ctx))
141
+ return { action: 'handled' };
142
+ return undefined;
143
+ });
144
+ pi.on('session_before_switch', (_event, ctx) => {
145
+ if (active.has(ctx.sessionManager.getSessionId()))
146
+ return { cancel: true };
147
+ return undefined;
148
+ });
149
+ return {
150
+ ownsState,
151
+ afterOperation: (ctx) => {
152
+ if (!ownsState(ctx))
153
+ return;
154
+ if (ctx.isIdle())
155
+ void runIdle(ctx);
156
+ else
157
+ ctx.abort();
158
+ },
159
+ };
160
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import type { RunnerResult } from '@nt-ai-lab/deterministic-agent-workflow-cli';
3
- import type { PiSessionIdResult } from '../../../platform/domain/pi-workflow-extension-types';
3
+ import type { PiSessionIdResult } from '../../../platform/domain/pi-workflow-extension-types.js';
4
4
  /** @riviere-role cli-entrypoint */
5
5
  export declare function translationNote(toolName: string): string;
6
6
  /** @riviere-role cli-entrypoint */
@@ -1,5 +1,5 @@
1
1
  import type { BaseWorkflowState, RehydratableWorkflow, TransitionContext } from '@nt-ai-lab/deterministic-agent-workflow-engine';
2
- import type { PiWorkflowExtension, PiWorkflowExtensionConfig } from '../../../platform/domain/pi-workflow-extension-types';
2
+ import type { PiWorkflowExtension, PiWorkflowExtensionConfig } from '../../../platform/domain/pi-workflow-extension-types.js';
3
3
  export declare const PI_IDLE_RECOVERY_MESSAGE: string;
4
4
  export declare const PI_SESSION_BRANCH_BLOCK_MESSAGE = "Pi session tree navigation and forks are disabled while a workflow is active.";
5
5
  /** @riviere-role cli-entrypoint */
@@ -4,9 +4,11 @@ import { createPreToolUseHandler, createWorkflowRunner, formatStopPreventionMess
4
4
  import { createStore } from '@nt-ai-lab/deterministic-agent-workflow-event-store';
5
5
  import { Type } from 'typebox';
6
6
  import { parsePiCommandArguments } from '../../../platform/domain/pi-command-arguments.js';
7
- import { hasPiWorkflowMarker, PI_WORKFLOW_MARKER_CUSTOM_TYPE, readPiSessionMetadata, } from '../../../platform/infra/external-clients/pi/pi-session-file.js';
7
+ import { hasPiWorkflowMarker, PI_WORKFLOW_MARKER_CUSTOM_TYPE, } from '../../../platform/infra/external-clients/pi/pi-session-file.js';
8
8
  import { getLatestPiAssistantSettlement, PiTranscriptReader, } from '../../../platform/infra/external-clients/pi/pi-transcript-reader.js';
9
9
  import { notifyRouteResult, readSessionId, readWorkflowInstruction, requireSessionFile, resolveDatabasePath, translationNote, } from './pi-workflow-extension-platform.js';
10
+ import { createPiWorkflowSessionOwnership } from './pi-workflow-session-ownership.js';
11
+ import { registerPiWorkflowAutomation } from './pi-workflow-automation.js';
10
12
  const PI_QUESTION_TOOL = 'question';
11
13
  const DEFAULT_COMMAND_NAME = 'workflow';
12
14
  const DEFAULT_TOOL_NAME = 'workflow';
@@ -23,6 +25,7 @@ export function createPiWorkflowExtension(config) {
23
25
  const databasePath = resolveDatabasePath(config.databasePath);
24
26
  const commandName = config.commandName ?? DEFAULT_COMMAND_NAME;
25
27
  const toolName = config.toolName ?? DEFAULT_TOOL_NAME;
28
+ const ownership = createPiWorkflowSessionOwnership(databasePath);
26
29
  const initializationBySession = new Map();
27
30
  const sessionStartsById = new Map();
28
31
  const recoveredAssistantBySession = new Map();
@@ -54,7 +57,7 @@ export function createPiWorkflowExtension(config) {
54
57
  appendToFile: () => undefined,
55
58
  now,
56
59
  transcriptReader: new PiTranscriptReader(() => ctx.sessionManager.getBranch()),
57
- sessionContext: { getMainSessionId: () => sessionId },
60
+ sessionContext: { getMainSessionId: () => ownership.delegatedParent(sessionId, store) ?? sessionId, },
58
61
  };
59
62
  }
60
63
  function useEngine(ctx, operation) {
@@ -63,6 +66,7 @@ export function createPiWorkflowExtension(config) {
63
66
  const engineDeps = buildEngineDeps(ctx, store);
64
67
  const now = engineDeps.now;
65
68
  const sessionId = ctx.sessionManager.getSessionId();
69
+ ownership.requireAccess(store, sessionId);
66
70
  const platform = {
67
71
  getPluginRoot: () => config.pluginRoot,
68
72
  now,
@@ -77,15 +81,6 @@ export function createPiWorkflowExtension(config) {
77
81
  store.db.close();
78
82
  }
79
83
  }
80
- function hasPersistedWorkflowState(sessionId) {
81
- const store = createStore(databasePath);
82
- try {
83
- return store.hasSessionStarted(sessionId);
84
- }
85
- finally {
86
- store.db.close();
87
- }
88
- }
89
84
  function markInitializationFailed(ctx, sessionId, detail) {
90
85
  return failSession(ctx, sessionId, `Pi workflow initialization failed: ${detail}`);
91
86
  }
@@ -114,23 +109,9 @@ export function createPiWorkflowExtension(config) {
114
109
  }
115
110
  function isInactive(ctx) {
116
111
  const session = readSessionId(ctx);
117
- return session.ok && initializationBySession.get(session.sessionId)?.type === 'inactive';
118
- }
119
- function parentSafetyFailure(event, ctx) {
120
- const parentSessionFile = ctx.sessionManager.getHeader()?.parentSession;
121
- if (parentSessionFile === undefined) {
122
- return event.reason === 'fork' ? 'Forked Pi session has no verifiable parent session file.' : undefined;
123
- }
124
- const parent = readPiSessionMetadata(parentSessionFile);
125
- const store = createStore(databasePath);
126
- try {
127
- return store.hasSessionStarted(parent.id) || parent.hasWorkflowMarker
128
- ? `Cannot fork Pi session ${parent.id}: its workflow is active.`
129
- : undefined;
130
- }
131
- finally {
132
- store.db.close();
133
- }
112
+ if (!session.ok)
113
+ return false;
114
+ return initializationBySession.get(session.sessionId)?.type === 'inactive';
134
115
  }
135
116
  function initializeSession(event, ctx, pi, sessionId) {
136
117
  try {
@@ -140,22 +121,29 @@ export function createPiWorkflowExtension(config) {
140
121
  return 'Pi session header does not match the active session UUID.';
141
122
  const activeBranchHasWorkflowMarker = hasPiWorkflowMarker(ctx.sessionManager.getBranch());
142
123
  const sessionHasWorkflowMarker = hasPiWorkflowMarker(ctx.sessionManager.getEntries());
124
+ const inheritedWorkflowState = ownership.usesInheritedWorkflow(sessionId);
125
+ const delegatedParent = ownership.delegatedParent(sessionId);
143
126
  const result = useEngine(ctx, (engine) => {
144
127
  const sqliteHasWorkflowState = engine.hasSessionStarted(sessionId);
145
128
  if (!sqliteHasWorkflowState) {
146
- const parentFailure = parentSafetyFailure(event, ctx);
129
+ const parentFailure = ownership.parentSafetyFailure(event, ctx);
147
130
  if (parentFailure !== undefined)
148
131
  return {
149
132
  type: 'error',
150
133
  output: parentFailure,
151
134
  };
152
135
  }
153
- if (sessionHasWorkflowMarker && !activeBranchHasWorkflowMarker)
136
+ if (delegatedParent !== undefined && !sqliteHasWorkflowState)
137
+ return {
138
+ type: 'error',
139
+ output: `Pi parent session ${delegatedParent} has no persisted workflow.`,
140
+ };
141
+ if (!inheritedWorkflowState && sessionHasWorkflowMarker && !activeBranchHasWorkflowMarker)
154
142
  return {
155
143
  type: 'error',
156
144
  output: `The active Pi branch does not contain this session's ${PI_WORKFLOW_MARKER_CUSTOM_TYPE} marker.`,
157
145
  };
158
- if (activeBranchHasWorkflowMarker !== sqliteHasWorkflowState)
146
+ if (!inheritedWorkflowState && activeBranchHasWorkflowMarker !== sqliteHasWorkflowState)
159
147
  return {
160
148
  type: 'error',
161
149
  output: `Pi transcript and SQLite workflow state disagree for session ${sessionId}.`,
@@ -244,7 +232,7 @@ export function createPiWorkflowExtension(config) {
244
232
  }
245
233
  sessionStartsById.set(session.sessionId, event);
246
234
  try {
247
- const hasPersistedWorkflow = hasPersistedWorkflowState(session.sessionId);
235
+ const hasPersistedWorkflow = ownership.hasPersistedWorkflow(session.sessionId);
248
236
  const hasTranscriptWorkflow = hasPiWorkflowMarker(ctx.sessionManager.getEntries());
249
237
  if (!hasPersistedWorkflow && !hasTranscriptWorkflow) {
250
238
  initializationBySession.set(session.sessionId, { type: 'inactive' });
@@ -304,7 +292,7 @@ export function createPiWorkflowExtension(config) {
304
292
  return { action: 'handled' };
305
293
  });
306
294
  pi.on('agent_settled', (_event, ctx) => {
307
- if (isInactive(ctx) || readinessFailure(ctx) !== undefined)
295
+ if (isInactive(ctx) || readinessFailure(ctx) !== undefined || automation.ownsState(ctx))
308
296
  return;
309
297
  const session = readSessionId(ctx);
310
298
  if (!session.ok)
@@ -353,6 +341,8 @@ export function createPiWorkflowExtension(config) {
353
341
  executionMode: 'sequential',
354
342
  async execute(_toolCallId, parameters, _signal, _onUpdate, ctx) {
355
343
  const result = runRoute(ctx, [parameters.operation, ...(parameters.args ?? [])], pi);
344
+ if (result.exitCode === 0)
345
+ automation.afterOperation(ctx);
356
346
  return {
357
347
  content: [{
358
348
  type: 'text',
@@ -368,12 +358,23 @@ export function createPiWorkflowExtension(config) {
368
358
  handler: async (rawArguments, ctx) => {
369
359
  try {
370
360
  const result = runRoute(ctx, parsePiCommandArguments(rawArguments), pi);
371
- notifyRouteResult(ctx, pi, result);
361
+ if (result.exitCode === 0)
362
+ automation.afterOperation(ctx);
363
+ if (result.exitCode !== 0 || (!automation.ownsState(ctx) && readinessFailure(ctx) === undefined))
364
+ notifyRouteResult(ctx, pi, result);
372
365
  }
373
366
  catch (error) {
374
367
  ctx.ui.notify(String(error), 'error');
375
368
  }
376
369
  },
377
370
  });
371
+ const automation = registerPiWorkflowAutomation(pi, {
372
+ databasePath,
373
+ ...(config.automation === undefined ? {} : { automation: config.automation }),
374
+ isReady: (ctx) => readinessFailure(ctx) === undefined && !ownership.usesInheritedWorkflow(ctx.sessionManager.getSessionId()),
375
+ getState: (ctx) => useEngine(ctx, (engine) => engine.getWorkflowState(ctx.sessionManager.getSessionId())),
376
+ runOperation: (ctx, args) => runRoute(ctx, args, pi),
377
+ fail: (ctx, reason) => markSafetyUnavailable(ctx, ctx.sessionManager.getSessionId(), reason),
378
+ });
378
379
  };
379
380
  }
@@ -0,0 +1,12 @@
1
+ import type { ExtensionContext, SessionStartEvent } from '@earendil-works/pi-coding-agent';
2
+ import { type SqliteEventStore } from '@nt-ai-lab/deterministic-agent-workflow-event-store';
3
+ interface PiWorkflowSessionOwnership {
4
+ delegatedParent(sessionId: string, store?: SqliteEventStore): string | undefined;
5
+ hasPersistedWorkflow(sessionId: string): boolean;
6
+ usesInheritedWorkflow(sessionId: string): boolean;
7
+ parentSafetyFailure(event: SessionStartEvent, ctx: ExtensionContext): string | undefined;
8
+ requireAccess(store: SqliteEventStore, sessionId: string): void;
9
+ }
10
+ /** @riviere-role cli-entrypoint */
11
+ export declare function createPiWorkflowSessionOwnership(databasePath: string): PiWorkflowSessionOwnership;
12
+ export {};
@@ -0,0 +1,72 @@
1
+ import { createStore, } from '@nt-ai-lab/deterministic-agent-workflow-event-store';
2
+ import { resolvePiMainSessionId } from '../../../platform/domain/pi-main-session.js';
3
+ import { readPiSessionMetadata } from '../../../platform/infra/external-clients/pi/pi-session-file.js';
4
+ /** @riviere-role cli-entrypoint */
5
+ export function createPiWorkflowSessionOwnership(databasePath) {
6
+ const delegatedParent = (sessionId, existingStore) => {
7
+ const store = existingStore ?? createStore(databasePath);
8
+ try {
9
+ if (store.hasSessionStarted(sessionId))
10
+ return undefined;
11
+ const mainSessionId = resolvePiMainSessionId(sessionId);
12
+ return mainSessionId === sessionId ? undefined : mainSessionId;
13
+ }
14
+ finally {
15
+ if (existingStore === undefined)
16
+ store.db.close();
17
+ }
18
+ };
19
+ const requireParent = (store, parentSessionId) => {
20
+ if (!store.hasSessionStarted(parentSessionId)) {
21
+ throw new TypeError(`Pi parent session ${parentSessionId} has no persisted workflow.`);
22
+ }
23
+ };
24
+ return {
25
+ delegatedParent,
26
+ hasPersistedWorkflow(sessionId) {
27
+ const store = createStore(databasePath);
28
+ try {
29
+ const parentSessionId = delegatedParent(sessionId, store);
30
+ if (parentSessionId !== undefined) {
31
+ requireParent(store, parentSessionId);
32
+ return true;
33
+ }
34
+ return store.hasSessionStarted(sessionId);
35
+ }
36
+ finally {
37
+ store.db.close();
38
+ }
39
+ },
40
+ usesInheritedWorkflow(sessionId) {
41
+ const store = createStore(databasePath);
42
+ try {
43
+ return delegatedParent(sessionId, store) !== undefined;
44
+ }
45
+ finally {
46
+ store.db.close();
47
+ }
48
+ },
49
+ parentSafetyFailure(event, ctx) {
50
+ const parentSessionFile = ctx.sessionManager.getHeader()?.parentSession;
51
+ if (parentSessionFile === undefined) {
52
+ return event.reason === 'fork' ? 'Forked Pi session has no verifiable parent session file.' : undefined;
53
+ }
54
+ const parent = readPiSessionMetadata(parentSessionFile);
55
+ const store = createStore(databasePath);
56
+ try {
57
+ return store.hasSessionStarted(parent.id) || parent.hasWorkflowMarker
58
+ ? `Cannot fork Pi session ${parent.id}: its workflow is active.`
59
+ : undefined;
60
+ }
61
+ finally {
62
+ store.db.close();
63
+ }
64
+ },
65
+ requireAccess(store, sessionId) {
66
+ const parentSessionId = delegatedParent(sessionId, store);
67
+ if (parentSessionId !== undefined) {
68
+ requireParent(store, parentSessionId);
69
+ }
70
+ },
71
+ };
72
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
- export { createPiWorkflowExtension, PI_IDLE_RECOVERY_MESSAGE, PI_SESSION_BRANCH_BLOCK_MESSAGE, } from './features/pi-extension/entrypoint/pi-workflow-extension';
2
- export type { PiWorkflowExtension, PiWorkflowExtensionConfig, } from './platform/domain/pi-workflow-extension-types';
3
- export { PiTranscriptReader } from './platform/infra/external-clients/pi/pi-transcript-reader';
1
+ export { createPiWorkflowExtension, PI_IDLE_RECOVERY_MESSAGE, PI_SESSION_BRANCH_BLOCK_MESSAGE, } from './features/pi-extension/entrypoint/pi-workflow-extension.js';
2
+ export type { PiWorkflowExtension, PiWorkflowExtensionConfig, PiWorkflowIdleContext, } from './platform/domain/pi-workflow-extension-types.js';
3
+ export { resolvePiMainSessionId } from './platform/domain/pi-main-session.js';
4
+ export { refreshPiContextWindow } from './platform/infra/external-clients/pi/pi-context-window.js';
5
+ export { PiTranscriptReader } from './platform/infra/external-clients/pi/pi-transcript-reader.js';
package/dist/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export { createPiWorkflowExtension, PI_IDLE_RECOVERY_MESSAGE, PI_SESSION_BRANCH_BLOCK_MESSAGE, } from './features/pi-extension/entrypoint/pi-workflow-extension.js';
2
+ export { resolvePiMainSessionId } from './platform/domain/pi-main-session.js';
3
+ export { refreshPiContextWindow } from './platform/infra/external-clients/pi/pi-context-window.js';
2
4
  export { PiTranscriptReader } from './platform/infra/external-clients/pi/pi-transcript-reader.js';
@@ -0,0 +1,2 @@
1
+ /** @riviere-role domain-service */
2
+ export declare function resolvePiMainSessionId(currentSessionId: string, environment?: Readonly<Record<string, string | undefined>>): string;
@@ -0,0 +1,15 @@
1
+ const PI_SUBAGENT_PARENT_SESSION = 'PI_SUBAGENT_PARENT_SESSION';
2
+ /** @riviere-role domain-service */
3
+ export function resolvePiMainSessionId(currentSessionId, environment = process.env) {
4
+ const current = currentSessionId.trim();
5
+ if (current.length === 0)
6
+ throw new TypeError('Pi returned an empty session UUID.');
7
+ const rawParent = environment[PI_SUBAGENT_PARENT_SESSION];
8
+ if (rawParent === undefined)
9
+ return current;
10
+ const parent = rawParent.trim();
11
+ if (parent.length === 0) {
12
+ throw new TypeError(`${PI_SUBAGENT_PARENT_SESSION} must contain a non-empty session UUID.`);
13
+ }
14
+ return parent;
15
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionFactory } from '@earendil-works/pi-coding-agent';
2
- import type { BaseWorkflowState, RehydratableWorkflow, TransitionContext, WorkflowDefinition } from '@nt-ai-lab/deterministic-agent-workflow-engine';
3
- import type { PlatformContext, PreToolUseHandlerConfig, RouteMap } from '@nt-ai-lab/deterministic-agent-workflow-cli';
2
+ import type { BaseWorkflowState, RehydratableWorkflow, ReviewBundleRequest, TransitionContext, WorkflowDefinition } from '@nt-ai-lab/deterministic-agent-workflow-engine';
3
+ import type { PlatformContext, PreToolUseHandlerConfig, RouteMap, ReviewAgentClient, ReviewCoordinatorResult } from '@nt-ai-lab/deterministic-agent-workflow-cli';
4
4
  /** @riviere-role value-object */
5
5
  export type PiWorkflowExtension = ExtensionFactory;
6
6
  /** @riviere-role value-object */
@@ -23,6 +23,16 @@ export type PiSessionIdResult = {
23
23
  readonly reason: string;
24
24
  };
25
25
  /** @riviere-role value-object */
26
+ export interface PiWorkflowIdleContext<TState> {
27
+ readonly sessionId: string;
28
+ readonly signal: AbortSignal;
29
+ readonly workingDirectory: string;
30
+ getState(): TState;
31
+ runOperation(operation: string, ...args: readonly string[]): string;
32
+ runReviews(request: Omit<ReviewBundleRequest, 'sessionId' | 'workingDirectory'>, client: ReviewAgentClient): Promise<ReviewCoordinatorResult>;
33
+ resumeWithFreshContext(stateInstructions: string): void;
34
+ }
35
+ /** @riviere-role value-object */
26
36
  export type PiWorkflowExtensionConfig<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string, TTransitionContext extends TransitionContext<TState, TStateName> = TransitionContext<TState, TStateName>> = Omit<PreToolUseHandlerConfig<TWorkflow, TState, TStateName>, 'questionToolName'> & {
27
37
  readonly workflowDefinition: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation, TTransitionContext>;
28
38
  readonly routes: RouteMap<TWorkflow, TState>;
@@ -33,4 +43,8 @@ export type PiWorkflowExtensionConfig<TWorkflow extends RehydratableWorkflow<TSt
33
43
  readonly commandName?: string;
34
44
  readonly toolName?: string;
35
45
  readonly stopPreventionMessage?: string;
46
+ readonly automation?: {
47
+ readonly ownsState: (state: TState) => boolean;
48
+ readonly onIdle: (context: PiWorkflowIdleContext<TState>) => Promise<void>;
49
+ };
36
50
  };
@@ -0,0 +1,3 @@
1
+ import { type AgentSession } from '@earendil-works/pi-coding-agent';
2
+ /** @riviere-role external-client-service */
3
+ export declare function refreshPiContextWindow(session: AgentSession, stateInstructions: string): void;
@@ -0,0 +1,17 @@
1
+ import { estimateTokens, } from '@earendil-works/pi-coding-agent';
2
+ /** @riviere-role external-client-service */
3
+ export function refreshPiContextWindow(session, stateInstructions) {
4
+ if (stateInstructions.trim().length === 0) {
5
+ throw new TypeError('Pi context state instructions must not be empty.');
6
+ }
7
+ if (!session.isIdle) {
8
+ throw new TypeError('Cannot refresh Pi context until the session is idle.');
9
+ }
10
+ if (!session.sessionManager.isPersisted()) {
11
+ throw new TypeError('Cannot refresh Pi context without a persisted session.');
12
+ }
13
+ const tokensBefore = session.messages.reduce((total, message) => total + estimateTokens(message), 0);
14
+ const boundary = session.sessionManager.appendCustomEntry('pi-context-window-boundary');
15
+ session.sessionManager.appendCompaction(stateInstructions, boundary, tokensBefore);
16
+ session.agent.state.messages = session.sessionManager.buildSessionContext().messages;
17
+ }
@@ -0,0 +1,3 @@
1
+ import { type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ /** @riviere-role external-client-service */
3
+ export declare function createPiExtensionContextWindow(pi: ExtensionAPI): (ctx: ExtensionContext, instructions: string) => void;
@@ -0,0 +1,77 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { buildSessionContext, } from '@earendil-works/pi-coding-agent';
3
+ const BOUNDARY_TYPE = 'pi-context-window-state';
4
+ function readBoundary(ctx) {
5
+ const branch = ctx.sessionManager.getBranch();
6
+ const boundary = branch.findLast((entry) => entry.type === 'custom' && entry.customType === BOUNDARY_TYPE);
7
+ if (boundary === undefined)
8
+ return undefined;
9
+ if (boundary.type !== 'custom' || typeof boundary.data !== 'string' || boundary.data.trim().length === 0) {
10
+ throw new TypeError('Pi context boundary has invalid state instructions.');
11
+ }
12
+ return {
13
+ boundary,
14
+ instructions: boundary.data,
15
+ entries: branch.slice(branch.indexOf(boundary))
16
+ };
17
+ }
18
+ /** @riviere-role external-client-service */
19
+ export function createPiExtensionContextWindow(pi) {
20
+ pi.on('context', (_event, ctx) => {
21
+ try {
22
+ const window = readBoundary(ctx);
23
+ if (window === undefined)
24
+ return;
25
+ return {
26
+ messages: [{
27
+ role: 'user',
28
+ content: window.instructions,
29
+ timestamp: Date.parse(window.boundary.timestamp),
30
+ }, ...buildSessionContext(window.entries).messages],
31
+ };
32
+ }
33
+ catch (error) {
34
+ ctx.abort();
35
+ ctx.ui.notify(`Pi context safety is unavailable: ${String(error)}`, 'error');
36
+ ctx.shutdown();
37
+ return { messages: [] };
38
+ }
39
+ });
40
+ pi.on('session_before_compact', (event, ctx) => {
41
+ try {
42
+ const window = readBoundary(ctx);
43
+ if (window === undefined)
44
+ return;
45
+ return {
46
+ compaction: {
47
+ summary: window.instructions,
48
+ firstKeptEntryId: window.boundary.id,
49
+ tokensBefore: event.preparation.tokensBefore,
50
+ },
51
+ };
52
+ }
53
+ catch (error) {
54
+ ctx.abort();
55
+ ctx.ui.notify(`Pi context safety is unavailable: ${String(error)}`, 'error');
56
+ ctx.shutdown();
57
+ return { cancel: true };
58
+ }
59
+ });
60
+ return (ctx, instructions) => {
61
+ if (instructions.trim().length === 0) {
62
+ throw new TypeError('Pi context state instructions must not be empty.');
63
+ }
64
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
65
+ throw new TypeError('Cannot refresh Pi context until the session is idle without pending messages.');
66
+ }
67
+ const sessionFile = ctx.sessionManager.getSessionFile();
68
+ if (sessionFile === undefined || !existsSync(sessionFile)) {
69
+ throw new TypeError('Cannot refresh Pi context without a persisted session.');
70
+ }
71
+ pi.appendEntry(BOUNDARY_TYPE, instructions);
72
+ const window = readBoundary(ctx);
73
+ if (window?.instructions !== instructions || window.boundary.id !== ctx.sessionManager.getLeafId()) {
74
+ throw new TypeError('Pi did not persist the requested context boundary.');
75
+ }
76
+ };
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nt-ai-lab/deterministic-agent-workflow-pi",
3
- "version": "0.5.5",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -23,9 +23,9 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "typebox": "^1.3.7",
26
- "@nt-ai-lab/deterministic-agent-workflow-cli": "0.4.8",
27
- "@nt-ai-lab/deterministic-agent-workflow-engine": "0.4.7",
28
- "@nt-ai-lab/deterministic-agent-workflow-event-store": "0.4.7"
26
+ "@nt-ai-lab/deterministic-agent-workflow-cli": "0.5.0",
27
+ "@nt-ai-lab/deterministic-agent-workflow-engine": "0.5.0",
28
+ "@nt-ai-lab/deterministic-agent-workflow-event-store": "0.5.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@earendil-works/pi-coding-agent": "^0.84.4",