@xingular/harness 1.3.1-unstable.8 → 1.4.0-unstable

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 (101) hide show
  1. package/CHANGELOG.md +122 -5
  2. package/NOTICE.md +26 -0
  3. package/builtin/skills/plugin-authoring/SKILL.md +20 -1
  4. package/dist/cli.js +2424 -1237
  5. package/dist/sdk/accounts/accounts.d.ts +26 -20
  6. package/dist/sdk/accounts/accounts.js +100 -63
  7. package/dist/sdk/agents/builtin.js +2 -0
  8. package/dist/sdk/commands/registry.d.ts +7 -0
  9. package/dist/sdk/commands/registry.js +1 -0
  10. package/dist/sdk/core/commandBudget.js +7 -2
  11. package/dist/sdk/core/workspaceWriteOperations.d.ts +24 -0
  12. package/dist/sdk/core/workspaceWriteOperations.js +21 -0
  13. package/dist/sdk/plugins/builtin.js +0 -4
  14. package/dist/sdk/plugins/discover.js +80 -4
  15. package/dist/sdk/plugins/install.js +26 -0
  16. package/dist/sdk/plugins/manifest.d.ts +2 -0
  17. package/dist/sdk/plugins/manifest.js +20 -1
  18. package/dist/sdk/prompt/compile.d.ts +4 -1
  19. package/dist/sdk/prompt/compile.js +8 -21
  20. package/dist/sdk/prompt/reporting.d.ts +2 -0
  21. package/dist/sdk/prompt/reporting.js +282 -0
  22. package/dist/sdk/prompt/standard.d.ts +12 -0
  23. package/dist/sdk/prompt/standard.js +53 -2
  24. package/dist/sdk/sessions/checkpoint.d.ts +7 -0
  25. package/dist/sdk/sessions/checkpoint.js +41 -0
  26. package/dist/sdk/sessions/compactionCheckpoint.d.ts +2 -0
  27. package/dist/sdk/sessions/compactionCheckpoint.js +2 -1
  28. package/dist/sdk/sessions/completionCheck.d.ts +49 -0
  29. package/dist/sdk/sessions/completionCheck.js +143 -0
  30. package/dist/sdk/sessions/continuation.js +1 -1
  31. package/dist/sdk/sessions/contracts.d.ts +97 -1
  32. package/dist/sdk/sessions/contracts.js +17 -0
  33. package/dist/sdk/sessions/goal.d.ts +145 -0
  34. package/dist/sdk/sessions/goal.js +405 -0
  35. package/dist/sdk/sessions/goalLoop.d.ts +37 -0
  36. package/dist/sdk/sessions/goalLoop.js +125 -0
  37. package/dist/sdk/sessions/goalPrompts.d.ts +4 -0
  38. package/dist/sdk/sessions/goalPrompts.js +93 -0
  39. package/dist/sdk/sessions/planMode.d.ts +31 -0
  40. package/dist/sdk/sessions/planMode.js +48 -3
  41. package/dist/sdk/sessions/registry.d.ts +6 -1
  42. package/dist/sdk/sessions/registry.js +4 -4
  43. package/dist/sdk/sessions/runStore.js +1 -0
  44. package/dist/sdk/sessions/runner.d.ts +9 -0
  45. package/dist/sdk/sessions/runner.js +65 -13
  46. package/dist/sdk/sessions/sessions.d.ts +10 -0
  47. package/dist/sdk/sessions/sessions.js +87 -10
  48. package/dist/sdk/sessions/stopGate.d.ts +78 -0
  49. package/dist/sdk/sessions/stopGate.js +143 -0
  50. package/dist/sdk/sessions/subagentService.d.ts +2 -0
  51. package/dist/sdk/sessions/subagentService.js +1 -1
  52. package/dist/sdk/sessions/workSlice.d.ts +61 -12
  53. package/dist/sdk/sessions/workSlice.js +102 -33
  54. package/dist/sdk/terminal/controller.d.ts +2 -2
  55. package/dist/sdk/terminal/controller.js +14 -4
  56. package/dist/sdk/terminal/credentialStore.d.ts +4 -0
  57. package/dist/sdk/terminal/credentialStore.js +33 -0
  58. package/dist/sdk/terminal/piExtension.d.ts +2 -1
  59. package/dist/sdk/terminal/piExtension.js +160 -27
  60. package/dist/sdk/terminal/terminals.js +27 -4
  61. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  62. package/dist/sdk/terminal/tui/commands.js +3 -0
  63. package/dist/sdk/terminal/tui/goalCommand.d.ts +19 -0
  64. package/dist/sdk/terminal/tui/goalCommand.js +83 -0
  65. package/dist/sdk/terminal/tui/planMode.d.ts +1 -1
  66. package/dist/sdk/terminal/tui/planMode.js +1 -1
  67. package/dist/sdk/terminal/tui/runtime.d.ts +3 -0
  68. package/dist/sdk/terminal/tui/runtime.js +21 -4
  69. package/dist/sdk/tools/askUser.js +1 -1
  70. package/dist/sdk/tools/todo.js +2 -1
  71. package/dist/terminal-opentui.js +1740 -627
  72. package/dist/terminal-pi.js +996 -407
  73. package/docs/architecture/02-sessions-and-runs.md +47 -11
  74. package/docs/architecture/04-prompt.md +83 -16
  75. package/docs/architecture/06-plugins-and-capabilities.md +16 -1
  76. package/docs/architecture/07-tools-and-gate.md +7 -2
  77. package/docs/architecture/09-accounts-and-connectors.md +9 -0
  78. package/docs/architecture/10-server-protocol.md +13 -0
  79. package/docs/architecture/11-terminal.md +38 -12
  80. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +2 -0
  81. package/docs/decisions/ADR-0008-flexible-reporting-guidance.md +3 -0
  82. package/docs/decisions/ADR-0063-unfinished-work-handoffs.md +2 -0
  83. package/docs/decisions/ADR-0068-scope-and-completion-report.md +60 -0
  84. package/docs/decisions/ADR-0069-stop-gate-and-finish.md +130 -0
  85. package/docs/decisions/ADR-0070-terminal-slice-continuation.md +46 -0
  86. package/docs/decisions/ADR-0071-completion-check.md +104 -0
  87. package/docs/decisions/ADR-0072-codex-prompts-and-goal.md +105 -0
  88. package/docs/decisions/ADR-0073-large-reporting-prompt.md +84 -0
  89. package/docs/parity.md +4 -1
  90. package/docs/releases/1.3.1-unstable.9.md +54 -0
  91. package/docs/releases/1.4.0-unstable.md +18 -0
  92. package/docs/releases/README.md +1 -1
  93. package/docs/study/00-index.md +10 -0
  94. package/docs/study/STUDY-0004-turn-end-controls.md +123 -0
  95. package/docs/study/STUDY-0005-skill-installation-guidance.md +146 -0
  96. package/docs/study/STUDY-0006-reporting-prompts.md +81 -0
  97. package/docs/study/arms/04-skills.md +6 -0
  98. package/docs/terminal-tui.md +17 -7
  99. package/package.json +1 -1
  100. package/dist/sdk/tools/checkpoint.d.ts +0 -27
  101. package/dist/sdk/tools/checkpoint.js +0 -154
@@ -0,0 +1,33 @@
1
+ import { controlCall } from "./control.js";
2
+ /** Long enough to wait for another holder's lease and the lock, as the host bounds both. */
3
+ const LEASE_CALL_MS = 60_000;
4
+ export function terminalCredentialStore(call = controlCall) {
5
+ return {
6
+ async read(providerId, options) {
7
+ const result = await call('accounts.credentials.read', { provider: providerId }, undefined, options?.signal);
8
+ return result.credential ?? undefined;
9
+ },
10
+ async list(options) {
11
+ const result = await call('accounts.credentials.list', {}, undefined, options?.signal);
12
+ return result.credentials ?? [];
13
+ },
14
+ async modify(providerId, fn, options) {
15
+ // The host holds the provider's lock from the lease until the commit, so this read-modify-write
16
+ // (a refresh or a sign-in) is serialized with every other process on the account.
17
+ const lease = await call('accounts.credentials.lease', { provider: providerId }, LEASE_CALL_MS, options?.signal);
18
+ let next;
19
+ try {
20
+ next = await fn(lease.current ?? undefined);
21
+ }
22
+ catch (error) {
23
+ await call('accounts.credentials.commit', { leaseId: lease.leaseId }).catch(() => undefined);
24
+ throw error;
25
+ }
26
+ const result = await call('accounts.credentials.commit', { leaseId: lease.leaseId, ...(next === undefined ? {} : { credential: next }) });
27
+ return result.credential ?? undefined;
28
+ },
29
+ async delete(providerId, options) {
30
+ await call('accounts.credentials.delete', { provider: providerId }, undefined, options?.signal);
31
+ }
32
+ };
33
+ }
@@ -1,4 +1,5 @@
1
1
  import { type AgentSession, type ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { type GoalController } from "../sessions/goal.js";
2
3
  export interface TerminalPlanRunState {
3
4
  active: boolean;
4
5
  allowedTools: ReadonlySet<string>;
@@ -8,4 +9,4 @@ export interface TerminalPlanRunState {
8
9
  plan: string;
9
10
  };
10
11
  }
11
- export default function (pi: ExtensionAPI, planRun?: TerminalPlanRunState, getSession?: () => AgentSession): Promise<void>;
12
+ export default function (pi: ExtensionAPI, planRun?: TerminalPlanRunState, getSession?: () => AgentSession, goal?: GoalController): Promise<void>;
@@ -3,7 +3,7 @@
3
3
  // queues for it, and gives the session the harness's own tools. It runs in the `pi` process,
4
4
  // so everything it knows about the harness comes through the session socket.
5
5
  import { readFileSync } from 'node:fs';
6
- import { createBashTool, createEditTool, createPowerShellTool, createWriteTool } from '@earendil-works/pi-coding-agent';
6
+ import { createBashTool, createEditTool, createEditToolDefinition, createPowerShellTool, createWriteTool, createWriteToolDefinition } from '@earendil-works/pi-coding-agent';
7
7
  import { Type } from 'typebox';
8
8
  import { bodyOf } from "../core/frontmatter.js";
9
9
  import { connectionFromEnv, socketCall, terminalCall } from "./client.js";
@@ -12,8 +12,11 @@ import { SPAWN_SUBAGENT, SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, sub
12
12
  import { PLAN_RESEARCH_TOOLS } from "../agents/planResearch.js";
13
13
  import { askUserTool } from "../tools/askUser.js";
14
14
  import { TODO_WRITE, todoTool } from "../tools/todo.js";
15
- import { CHECKPOINT_WRITE, checkpointTool } from "../tools/checkpoint.js";
16
- import { createSliceState, parseWorkSlice, workSliceGuard } from "../sessions/workSlice.js";
15
+ import { createStopGateState, handoffFields, sessionCheckModel, stopCheckpointReason, stopGate, stopGateNotice, stopOutcome, stopSummary, STOP_GATE_EXTENSION } from "../sessions/stopGate.js";
16
+ import { launchSwitchOff } from "../sessions/completionCheck.js";
17
+ import { GOAL_STATE_ENTRY, GOAL_TOOLS, goalTools } from "../sessions/goal.js";
18
+ import { GOAL_EXTENSION, goalLoop, goalNotice } from "../sessions/goalLoop.js";
19
+ import { createSliceState, DEFAULT_SLICE_POLICY, parseWorkSlice, sliceContinuation, workSliceGuard } from "../sessions/workSlice.js";
17
20
  import { DEFAULT_COMMAND_TIMEOUT_SECONDS, withCommandBudget } from "../core/commandBudget.js";
18
21
  import { SESSION_NAME, deriveSessionName, sessionNameTool } from "../tools/sessionName.js";
19
22
  import { CAPABILITY_DISCOVERY, describeCapabilities } from "../tools/getCapabilities.js";
@@ -26,7 +29,7 @@ import { skillInvocations } from "./tui/skillInvocation.js";
26
29
  import { inspectWorkspaceWrite } from "../sessions/workspaceBoundary.js";
27
30
  import { createWorkspaceShellTool } from "../core/workspaceBashTool.js";
28
31
  import { createWorkspaceBashOperations, runWithWorkspaceWriteScope } from "../core/workspaceSandbox.js";
29
- import { withWorkspaceWriteAuthorization, workspaceEditOperations, workspaceWriteOperations } from "../core/workspaceWriteOperations.js";
32
+ import { createWorkspaceFileTools } from "../core/workspaceWriteOperations.js";
30
33
  import { BUDGET_GUARD, OUTPUT_TRIM, budgetGuard, outputTrim } from "../plugins/firstParty.js";
31
34
  /** How long reply text gathers before it is sent on to a paired phone. */
32
35
  const STREAM_FLUSH_MS = 150;
@@ -49,7 +52,7 @@ async function withParentCancellation(signal, work) {
49
52
  signal?.removeEventListener('abort', stop);
50
53
  }
51
54
  }
52
- export default async function (pi, planRun, getSession) {
55
+ export default async function (pi, planRun, getSession, goal) {
53
56
  await terminalCall('startup', { phase: 'Pi extension loaded' });
54
57
  let context = null;
55
58
  let nativeSubagents = false;
@@ -156,8 +159,9 @@ export default async function (pi, planRun, getSession) {
156
159
  workspaceWriteScope: () => planRun?.active ? 'read-only' : undefined
157
160
  });
158
161
  pi.registerTool(createWorkspaceShellTool({ createBashTool, createPowerShellTool }, process.cwd(), workspaceBash));
159
- pi.registerTool(withWorkspaceWriteAuthorization(createWriteTool(process.cwd(), { operations: workspaceWriteOperations }), writeAuthorizations));
160
- pi.registerTool(withWorkspaceWriteAuthorization(createEditTool(process.cwd(), { operations: workspaceEditOperations }), writeAuthorizations));
162
+ const nativeFileTools = createWorkspaceFileTools({ createWriteTool, createWriteToolDefinition, createEditTool, createEditToolDefinition }, process.cwd(), writeAuthorizations);
163
+ pi.registerTool(nativeFileTools.write);
164
+ pi.registerTool(nativeFileTools.edit);
161
165
  // The current name and this run's todo ride with each request; they never change the stable prefix.
162
166
  pi.on('context', (event) => {
163
167
  if (!sessionState || stateDelivered)
@@ -174,16 +178,64 @@ export default async function (pi, planRun, getSession) {
174
178
  };
175
179
  });
176
180
  compactionCheckpoint({ planInstructions: () => planRun?.active ? PLAN_INSTRUCTIONS : null })(pi);
177
- // A terminal reply is bounded like any other (PROP-0041): a checkpoint at the boundary, then the
178
- // person decides whether to continue.
179
- const slicePolicy = parseWorkSlice(undefined);
181
+ // A terminal reply is bounded like any other (PROP-0041): a checkpoint at each boundary, and the
182
+ // reply continues in place up to the policy's slice cap (ADR-0070) before the person decides.
183
+ let slicePolicy;
184
+ try {
185
+ slicePolicy = parseWorkSlice(undefined);
186
+ }
187
+ catch (error) {
188
+ slicePolicy = { ...DEFAULT_SLICE_POLICY };
189
+ console.error(`${error instanceof Error ? error.message : String(error)} Using the default work slices.`);
190
+ }
180
191
  const sliceState = createSliceState(slicePolicy);
181
192
  // `XINGULAR_WORK_SLICE=off` at launch leaves terminal replies unbounded, as before PROP-0041.
193
+ // One stop-gate state per terminal session; each reply resets it.
194
+ const gateState = createStopGateState();
195
+ // A goal-claim check's model request counts with the reply's usage, like any other request.
196
+ const recordCheckUsage = (raw) => {
197
+ const message = raw;
198
+ const reported = message.usage;
199
+ if (!reported)
200
+ return;
201
+ usage.inputTokens += reported.input ?? 0;
202
+ usage.outputTokens += reported.output ?? 0;
203
+ usage.cacheReadTokens += reported.cacheRead ?? 0;
204
+ usage.cacheWriteTokens += reported.cacheWrite ?? 0;
205
+ usage.requests += 1;
206
+ if (typeof reported.reasoning === 'number')
207
+ reasoningTokens = (reasoningTokens ?? 0) + reported.reasoning;
208
+ const cost = reported.cost?.total;
209
+ if (typeof cost === 'number') {
210
+ usage.costUsd += cost;
211
+ costReported = true;
212
+ }
213
+ const identity = { model: message.model, provider: message.provider };
214
+ const { costUsd, ...tokens } = usage;
215
+ void terminalCall('usage', {
216
+ ...tokens,
217
+ ...(costReported ? { costUsd, costSource: 'harness' } : {}),
218
+ ...(reasoningTokens === undefined ? {} : { reasoningTokens }),
219
+ ...identity,
220
+ request: {
221
+ index: usage.requests,
222
+ ...identity,
223
+ responseId: message.responseId,
224
+ inputTokens: reported.input ?? 0,
225
+ cacheReadTokens: reported.cacheRead ?? 0,
226
+ cacheWriteTokens: reported.cacheWrite ?? 0,
227
+ outputTokens: reported.output ?? 0,
228
+ ...(typeof reported.reasoning === 'number' ? { reasoningTokens: reported.reasoning } : {}),
229
+ ...(typeof cost === 'number' ? { costUsd: cost, costSource: 'harness' } : {}),
230
+ ...(typeof message.durationMs === 'number' ? { durationMs: message.durationMs } : {}),
231
+ stopReason: message.stopReason
232
+ }
233
+ }).catch(() => undefined);
234
+ };
182
235
  if (slicePolicy.enabled)
183
236
  workSliceGuard({
184
237
  policy: slicePolicy,
185
238
  state: sliceState,
186
- checkpointAvailable: () => !planRun?.active || planRun.allowedTools.has(CHECKPOINT_WRITE),
187
239
  onRefuse: (event, reason) => { pendingRefusals.push(terminalCall('refusal', { toolCallId: event.toolCallId, name: event.toolName, code: 'work_slice', reason: reason.slice(0, 2_000) }).catch(() => { })); }
188
240
  })(pi);
189
241
  // Only the latest screenshots stay in what the model sees (world/images.ts).
@@ -438,6 +490,89 @@ export default async function (pi, planRun, getSession) {
438
490
  continue: true
439
491
  };
440
492
  });
493
+ // The stop gate (ADR-0069), after subagent delivery and plan repair so it sees their
494
+ // continuations: a terminal reply that ends with open todo items is sent back up to its cap.
495
+ // Plan replies are not gated.
496
+ if (!launchSwitchOff('XINGULAR_STOP_GATE'))
497
+ stopGate({
498
+ state: gateState,
499
+ active: () => !planRun?.active,
500
+ sliceEnded: () => sliceState.endedBy !== null,
501
+ failed: () => Boolean(failure),
502
+ onEvent: (event) => {
503
+ void terminalCall('notice', { kind: 'notify', level: 'info', extension: STOP_GATE_EXTENSION, text: stopGateNotice(event) }).catch(() => undefined);
504
+ }
505
+ })(pi);
506
+ // A /goal (ADR-0072): while one is active, a reply that would end continues in place. After the
507
+ // stop gate, so open todo items are nudged first; its claim check is the only completion check.
508
+ if (goal) {
509
+ for (const spec of goalTools(() => goal))
510
+ pi.registerTool({
511
+ name: spec.name,
512
+ label: spec.label,
513
+ description: spec.description,
514
+ parameters: spec.parameters(Type),
515
+ async execute(_id, input) {
516
+ const result = await spec.execute(input);
517
+ return { content: [{ type: 'text', text: typeof result === 'string' ? result : result.text }], details: undefined };
518
+ }
519
+ });
520
+ // The goal's tools are offered to the model only while a goal exists.
521
+ const syncGoalTools = (state) => {
522
+ const active = pi.getActiveTools();
523
+ const wanted = state !== null && state.status !== 'complete';
524
+ const has = GOAL_TOOLS.some((name) => active.includes(name));
525
+ if (wanted && !GOAL_TOOLS.every((name) => active.includes(name)))
526
+ pi.setActiveTools([...active, ...GOAL_TOOLS.filter((name) => !active.includes(name))]);
527
+ else if (!wanted && has)
528
+ pi.setActiveTools(active.filter((name) => !GOAL_TOOLS.includes(name)));
529
+ };
530
+ const unsubscribe = goal.subscribe((state) => {
531
+ try {
532
+ pi.appendEntry(GOAL_STATE_ENTRY, { goal: state });
533
+ syncGoalTools(state);
534
+ }
535
+ catch {
536
+ // The session is closing; the next one restores from its own branch.
537
+ }
538
+ });
539
+ pi.on('session_shutdown', () => { unsubscribe(); });
540
+ pi.on('session_start', (_event, ctx) => {
541
+ // A goal is restored from the branch, and waits for the person: nothing resumes by itself.
542
+ const entries = (ctx.sessionManager.getBranch?.() ?? []);
543
+ const saved = [...entries].reverse().find((entry) => entry.type === 'custom' && entry.customType === GOAL_STATE_ENTRY)?.data?.goal ?? null;
544
+ goal.restore(saved && (saved.status === 'active') ? { ...saved, status: 'paused', claimed: false } : saved);
545
+ syncGoalTools(goal.get());
546
+ });
547
+ goalLoop({
548
+ controller: goal,
549
+ check: launchSwitchOff('XINGULAR_COMPLETION_CHECK') ? null : sessionCheckModel,
550
+ active: () => !planRun?.active,
551
+ sliceEnded: () => sliceState.endedBy !== null,
552
+ failed: () => Boolean(failure),
553
+ recordUsage: recordCheckUsage,
554
+ onEvent: (event) => {
555
+ void terminalCall('notice', { kind: 'notify', level: 'info', extension: GOAL_EXTENSION, text: goalNotice(event) }).catch(() => undefined);
556
+ }
557
+ })(pi);
558
+ }
559
+ // After the stop gate, which stays silent at a slice boundary: a reply that did work and reached
560
+ // its budget continues as the next slice of the same run (ADR-0070).
561
+ if (slicePolicy.enabled)
562
+ sliceContinuation({
563
+ policy: slicePolicy,
564
+ state: sliceState,
565
+ active: () => !planRun?.active,
566
+ failed: () => Boolean(failure),
567
+ blocked: () => goal?.get()?.status === 'blocked',
568
+ goalActive: () => goal?.active() === true,
569
+ todo: () => gateState.todo,
570
+ next: () => handoffFields(gateState, goal?.get() ?? null).next,
571
+ onContinue: (ended) => {
572
+ void terminalCall('checkpoint', { summary: `Work slice ${ended.slice} reached its limit after ${ended.steps} steps; the reply continues.`, ...handoffFields(gateState, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: ended.slice, reason: 'budget' }).catch(() => undefined);
573
+ void terminalCall('notice', { kind: 'notify', level: 'info', extension: STOP_GATE_EXTENSION, text: `Work slice ${ended.slice} reached ${ended.limit ?? 'its limit'}; continuing with slice ${ended.slice + 1} of up to ${slicePolicy.maxSlices}.` }).catch(() => undefined);
574
+ }
575
+ })(pi);
441
576
  // Reply text as it streams, batched, for a paired phone (remote control). The completed message
442
577
  // carries the same id and replaces whatever streamed.
443
578
  let streamId = null;
@@ -642,9 +777,20 @@ export default async function (pi, planRun, getSession) {
642
777
  subagentParent = false;
643
778
  // A reply that stopped at its work-slice boundary is not a completed task: the record says so,
644
779
  // so a host can offer the continuation instead of treating it as done (PROP-0041).
645
- const reason = sliceState.endedBy ? 'slice_complete' : undefined;
646
- const code = sliceState.endedBy === 'compaction-thrash' ? 'compaction_thrash' : sliceState.endedBy === 'budget' ? 'slice_budget' : undefined;
647
- await terminalCall('settled', { error: failure, ...(failureCode ? { errorCode: failureCode } : {}), aborted: event.aborted || messageAborted, ...(reason ? { reason } : {}), ...(code ? { code } : {}) });
780
+ // A reply that ended with open todo items is not completed either (ADR-0069): it stopped for the
781
+ // person, or the stop gate's nudges ran out. The harness writes its checkpoint from the todo list.
782
+ const aborted = event.aborted || messageAborted;
783
+ const stop = !sliceState.endedBy && !failure && !aborted && !planRun?.active ? stopOutcome(gateState, goal?.get() ?? null) : null;
784
+ if (stop) {
785
+ await terminalCall('checkpoint', { summary: stopSummary(stop), ...handoffFields(gateState, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: sliceState.slice, reason: stopCheckpointReason(stop) }).catch(() => undefined);
786
+ }
787
+ else if (sliceState.endedBy && !failure && !aborted) {
788
+ // The guard ended the reply and promised the model the harness would keep its state.
789
+ await terminalCall('checkpoint', { summary: `The reply stopped after ${sliceState.steps} steps at its work-slice boundary.`, ...handoffFields(gateState, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: sliceState.slice, reason: sliceState.endedBy }).catch(() => undefined);
790
+ }
791
+ const reason = sliceState.endedBy ? 'slice_complete' : stop?.code;
792
+ const code = sliceState.endedBy === 'compaction-thrash' ? 'compaction_thrash' : sliceState.endedBy === 'budget' ? 'slice_budget' : stop?.code;
793
+ await terminalCall('settled', { error: failure, ...(failureCode ? { errorCode: failureCode } : {}), aborted, ...(reason ? { reason } : {}), ...(code ? { code } : {}) });
648
794
  turnActive = false;
649
795
  });
650
796
  pi.on('user_bash', async () => {
@@ -695,19 +841,6 @@ export default async function (pi, planRun, getSession) {
695
841
  return { content: [{ type: 'text', text: typeof result === 'string' ? result : result.text }], details: undefined };
696
842
  }
697
843
  });
698
- // A bounded reply records its handoff here; the terminal reply is its own slice.
699
- let checkpointRecorded = Promise.resolve();
700
- const checkpoint = checkpointTool({ emit: (_type, payload) => { checkpointRecorded = terminalCall('checkpoint', payload); } }, 1);
701
- pi.registerTool({
702
- name: checkpoint.name, label: checkpoint.label, description: checkpoint.description,
703
- parameters: Type.Unsafe(checkpoint.parameters(Type)),
704
- async execute(_id, input) {
705
- checkpointRecorded = Promise.resolve();
706
- const result = await checkpoint.execute(input);
707
- await checkpointRecorded;
708
- return { content: [{ type: 'text', text: typeof result === 'string' ? result : result.text }], details: undefined };
709
- }
710
- });
711
844
  const ask = askUserTool({ requestAnswers: questions => terminalCall('questions', { questions }, 1_230_000) });
712
845
  pi.registerTool({
713
846
  name: ask.name, label: ask.label, description: ask.description,
@@ -28,6 +28,7 @@ import { isActiveStatus } from "../sessions/contracts.js";
28
28
  import { findRun, listRuns, loadRun, readEvents, recordedSubagents, saveRun, updateRun } from "../sessions/runStore.js";
29
29
  import { combineWorkspaceScope, inspectRunWorkspaceWrite } from "../sessions/workspaceBoundary.js";
30
30
  import { TODO_WRITE } from "../tools/todo.js";
31
+ import { GOAL_TOOLS } from "../sessions/goal.js";
31
32
  import { DEFAULT_TOOL_NAMES } from "../tools/definitions.js";
32
33
  import { harnessTools } from "../tools/harnessTools.js";
33
34
  import { HARNESS_PLUGINS } from "../plugins/builtin.js";
@@ -431,6 +432,24 @@ export class Terminals {
431
432
  return accounts.overview();
432
433
  case 'accounts.session':
433
434
  return { accountId: live.account.id, label: live.account.label };
435
+ // The terminal's Pi keeps no credential file: it reads and writes its own account's credentials
436
+ // here, under the store's cross-process lock, never an account the caller names.
437
+ case 'accounts.credentials.read':
438
+ return { credential: (await accounts.readCredential(live.account.id, text('provider'))) ?? null };
439
+ case 'accounts.credentials.list':
440
+ return { credentials: await accounts.listCredentials(live.account.id) };
441
+ case 'accounts.credentials.lease': {
442
+ const lease = await accounts.leaseCredential(live.account.id, text('provider'));
443
+ return { leaseId: lease.leaseId, current: lease.current ?? null };
444
+ }
445
+ case 'accounts.credentials.commit': {
446
+ const credential = params.credential && typeof params.credential === 'object' ? params.credential : undefined;
447
+ return { credential: (await accounts.commitCredentialLease(live.account.id, text('leaseId'), credential)) ?? null };
448
+ }
449
+ case 'accounts.credentials.delete':
450
+ await accounts.writeCredential(live.account.id, text('provider'), null);
451
+ accounts.invalidate(live.account.id);
452
+ return {};
434
453
  case 'accounts.create':
435
454
  return accounts.create(text('label'), optional('authKind') ?? 'subscription');
436
455
  case 'accounts.rename':
@@ -862,7 +881,7 @@ export class Terminals {
862
881
  }
863
882
  if (inspection.outside)
864
883
  return { allowed: false, requiresApproval: true, rememberable: false, standing: 'switch', reason: inspection.reason, workspaceRoot };
865
- if (name === SESSION_NAME || name === TODO_WRITE || name === SPAWN_SUBAGENT || name === WAIT_SUBAGENTS || READ_ONLY_TOOLS.has(name) || this.worldReadOnly.has(name) || this.options.apps.toolNames.includes(name) || ['bash', 'edit', 'write'].includes(name)) {
884
+ if (name === SESSION_NAME || name === TODO_WRITE || GOAL_TOOLS.includes(name) || name === SPAWN_SUBAGENT || name === WAIT_SUBAGENTS || READ_ONLY_TOOLS.has(name) || this.worldReadOnly.has(name) || this.options.apps.toolNames.includes(name) || ['bash', 'edit', 'write'].includes(name)) {
866
885
  return { allowed: true, workspaceRoot };
867
886
  }
868
887
  return { allowed: false, requiresApproval: true, rememberable: true, reason: 'This native action requires approval.', workspaceRoot };
@@ -1006,8 +1025,12 @@ export class Terminals {
1006
1025
  await service.verify(run.runId, params.history);
1007
1026
  return {};
1008
1027
  }
1009
- if (call === 'terminal.subagents.delivery')
1010
- return service.delivery(run.runId, params.history, params.settle === true);
1028
+ if (call === 'terminal.subagents.delivery') {
1029
+ // Waiting for children to settle ends when the parent is interrupted, a stall included.
1030
+ const stop = new AbortController();
1031
+ run.onInterrupt(() => stop.abort());
1032
+ return service.delivery(run.runId, params.history, params.settle === true, stop.signal);
1033
+ }
1011
1034
  if (call === 'terminal.subagents.call') {
1012
1035
  const tool = subagentTools(service, run.runId).find(tool => tool.name === params.name);
1013
1036
  if (!tool)
@@ -1221,7 +1244,7 @@ export class Terminals {
1221
1244
  allowToolGlobally: target => { this.options.approvals.allowTool('pi', target); },
1222
1245
  native: (nativeSessionId) => bus.emit({ type: 'terminal.native', sessionId, nativeSessionId }),
1223
1246
  telemetry: (event) => bus.emit({ type: 'terminal.telemetry', sessionId, event }),
1224
- interrupt: runId => this.options.runs.interrupt(runId),
1247
+ interrupt: (runId, stop) => this.options.runs.interrupt(runId, stop),
1225
1248
  resolveApproval: (runId, id, decision) => this.options.runs.resolveApproval(runId, id, decision),
1226
1249
  answerQuestion: (runId, id, answer) => this.options.runs.answerQuestion(runId, id, answer),
1227
1250
  answerQuestions: (runId, batchId, answers) => this.options.runs.answerQuestions(runId, batchId, answers)
@@ -25,6 +25,8 @@ export interface TerminalCommandHost {
25
25
  todo(): Promise<readonly TodoEntry[]>;
26
26
  showTodo(items: readonly TodoEntry[]): void;
27
27
  plan(brief: string): void;
28
+ /** Runs `/goal` and returns the text to show (ADR-0072). */
29
+ goal(argument: string): Promise<string>;
28
30
  /** Asks a side question in its own tool-free session; empty text dismisses the one showing. */
29
31
  btw(question: string): Promise<void>;
30
32
  shutdown(): void;
@@ -227,6 +227,9 @@ export function createTerminalCommands(host) {
227
227
  host.plan(argument);
228
228
  break;
229
229
  }
230
+ case 'goal':
231
+ host.ui.notify(await host.goal(argument), 'info');
232
+ break;
230
233
  case 'quit':
231
234
  host.shutdown();
232
235
  break;
@@ -0,0 +1,19 @@
1
+ import { GOAL_CUSTOM_TYPE, type GoalController } from "../../sessions/goal.js";
2
+ export interface GoalCommandDeps {
3
+ controller: GoalController;
4
+ /** True when no reply is running. */
5
+ idle(): boolean;
6
+ /** Sends a hidden message and starts a reply with it. */
7
+ start(content: string): Promise<void>;
8
+ /** Sends a hidden message into the running reply. */
9
+ steer(content: string): Promise<void>;
10
+ /** Records a hidden message without starting a reply. */
11
+ note(content: string): Promise<void>;
12
+ /** Stops the running reply. */
13
+ interrupt(): Promise<void>;
14
+ confirm(title: string, message: string): Promise<boolean>;
15
+ input(title: string, value: string): Promise<string | undefined>;
16
+ }
17
+ export { GOAL_CUSTOM_TYPE };
18
+ /** Runs one `/goal` and returns the text to show the person. */
19
+ export declare function runGoalCommand(argument: string, deps: GoalCommandDeps): Promise<string>;
@@ -0,0 +1,83 @@
1
+ // `/goal` in the terminal (ADR-0072): set, view, edit, pause, resume and clear a goal. Adapted from
2
+ // OpenAI Codex (Apache-2.0; see NOTICE.md): codex-rs/tui/src/chatwidget/slash_dispatch.rs and
3
+ // goal_menu.rs. The model never creates a goal: the person does, here.
4
+ import { GOAL_CUSTOM_TYPE, GoalError, continuationPrompt, goalSummary, objectiveUpdatedPrompt, parseGoalCommand, provenanceText } from "../../sessions/goal.js";
5
+ export { GOAL_CUSTOM_TYPE };
6
+ /** Runs one `/goal` and returns the text to show the person. */
7
+ export async function runGoalCommand(argument, deps) {
8
+ const { controller } = deps;
9
+ const command = parseGoalCommand(argument);
10
+ try {
11
+ switch (command.kind) {
12
+ case 'error': return command.message;
13
+ case 'show': return goalSummary(controller.get());
14
+ case 'set': {
15
+ const existing = controller.get();
16
+ if (existing && existing.status !== 'complete') {
17
+ const replace = await deps.confirm('Replace goal?', `The current goal is not finished:\n${existing.objective}\n\nReplace it with the new one and start it now?`);
18
+ if (!replace)
19
+ return 'Kept the current goal.';
20
+ }
21
+ // A reply that is running stops first: the new goal starts from a clean boundary.
22
+ if (!deps.idle())
23
+ await deps.interrupt();
24
+ const goal = controller.set(command.objective, command.limits);
25
+ await deps.start(`${provenanceText('set', goal)}\n\n${continuationPrompt(goal)}`);
26
+ return `Goal active\nObjective: ${goal.objective}`;
27
+ }
28
+ case 'edit': {
29
+ const existing = controller.get();
30
+ if (!existing)
31
+ return 'No goal to edit. Use /goal <objective>.';
32
+ const objective = command.objective ?? await deps.input('Edit goal', existing.objective);
33
+ if (!objective?.trim())
34
+ return 'Goal unchanged.';
35
+ const goal = controller.edit(objective);
36
+ const content = `${provenanceText('edited', goal)}\n\n${objectiveUpdatedPrompt(goal)}`;
37
+ if (deps.idle())
38
+ await deps.start(`${provenanceText('edited', goal)}\n\n${continuationPrompt(goal)}`);
39
+ else
40
+ await deps.steer(content);
41
+ return `Goal updated\nObjective: ${goal.objective}`;
42
+ }
43
+ case 'pause': {
44
+ const goal = controller.get();
45
+ if (!goal)
46
+ return 'No goal to pause.';
47
+ if (!deps.idle())
48
+ await deps.interrupt();
49
+ const paused = controller.pause();
50
+ await deps.note(provenanceText('paused', paused));
51
+ return `Goal ${paused?.status === 'paused' ? 'paused' : `is ${paused?.status.replace('_', ' ')}`}. Use /goal resume to continue.`;
52
+ }
53
+ case 'resume': {
54
+ const goal = controller.get();
55
+ if (!goal)
56
+ return 'No goal to resume.';
57
+ if (goal.status === 'complete')
58
+ return 'The goal is complete. Set a new one with /goal <objective>.';
59
+ const resumed = controller.resume();
60
+ const content = `${provenanceText('resumed', resumed)}\n\n${continuationPrompt(resumed)}`;
61
+ if (deps.idle())
62
+ await deps.start(content);
63
+ else
64
+ await deps.steer(content);
65
+ return 'Goal active';
66
+ }
67
+ case 'clear': {
68
+ if (!controller.get())
69
+ return 'No goal to clear.';
70
+ if (!deps.idle() && controller.active())
71
+ await deps.interrupt();
72
+ controller.clear();
73
+ await deps.note(provenanceText('cleared', null));
74
+ return 'Goal cleared';
75
+ }
76
+ }
77
+ }
78
+ catch (error) {
79
+ if (error instanceof GoalError)
80
+ return error.message;
81
+ throw error;
82
+ }
83
+ }
@@ -1,2 +1,2 @@
1
- export { normalModeTools, parseProposedPlan, planModeTools, QUESTION_TOOL_NAMES } from "../../sessions/planMode.js";
1
+ export { normalModeTools, normalToolsForBranch, parseProposedPlan, planModeTools, PLAN_TOOLS_ENTRY, QUESTION_TOOL_NAMES } from "../../sessions/planMode.js";
2
2
  export type { ProposedPlan } from "../../sessions/planMode.js";
@@ -1,2 +1,2 @@
1
1
  // The terminal's plan mode uses the session's read-only tool set.
2
- export { normalModeTools, parseProposedPlan, planModeTools, QUESTION_TOOL_NAMES } from "../../sessions/planMode.js";
2
+ export { normalModeTools, normalToolsForBranch, parseProposedPlan, planModeTools, PLAN_TOOLS_ENTRY, QUESTION_TOOL_NAMES } from "../../sessions/planMode.js";
@@ -1,4 +1,5 @@
1
1
  import { type AgentSession, type AgentSessionEventListener, type AgentSessionRuntime, type AgentSessionServices, type ExtensionUIContext } from '@earendil-works/pi-coding-agent';
2
+ import { GoalController } from "../../sessions/goal.js";
2
3
  import type { SessionAccount } from "../accountsMenu.js";
3
4
  import type { QuestionAnswer } from "../../sessions/registry.js";
4
5
  import type { TerminalQuestion } from "./questions.js";
@@ -104,6 +105,8 @@ export interface TerminalRuntime {
104
105
  installationDeviceId(): Promise<string>;
105
106
  reload(): Promise<void>;
106
107
  interrupt(): Promise<void>;
108
+ /** This session's /goal (ADR-0072). */
109
+ readonly goal: GoalController;
107
110
  dispose(): Promise<void>;
108
111
  }
109
112
  /** Saved native history stays in the prepared project folder, including symlink targets. */
@@ -2,7 +2,9 @@ import { createAgentSession, createAgentSessionRuntime, DefaultResourceLoader, M
2
2
  import { closeSync, existsSync, openSync, readSync, realpathSync } from 'node:fs';
3
3
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import terminalExtension from "../piExtension.js";
5
- import { normalModeTools, planModeTools, QUESTION_TOOL_NAMES } from "./planMode.js";
5
+ import { GoalController } from "../../sessions/goal.js";
6
+ import { terminalCredentialStore } from "../credentialStore.js";
7
+ import { normalModeTools, normalToolsForBranch, planModeTools, PLAN_TOOLS_ENTRY, QUESTION_TOOL_NAMES } from "./planMode.js";
6
8
  import { claimTerminalControl, controlCall } from "../control.js";
7
9
  import { observeProviderResponses } from "../../usage/limits/capture.js";
8
10
  import { limitHeaders } from "../../usage/limits/normalize.js";
@@ -126,7 +128,8 @@ export async function createTerminalRuntime(args, host) {
126
128
  const account = identity && typeof identity.accountId === 'string' && typeof identity.label === 'string'
127
129
  ? Object.freeze({ accountId: identity.accountId, label: identity.label }) : null;
128
130
  await loadPiAdapter();
129
- const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, 'auth.json'), modelsPath: join(agentDir, 'models.json') });
131
+ // Credentials live in the host's store, read and refreshed under its lock (`terminalCredentialStore`).
132
+ const modelRuntime = await ModelRuntime.create({ credentials: terminalCredentialStore(), modelsPath: join(agentDir, 'models.json') });
130
133
  // This process makes the model requests, so it is where response headers are seen. Only the
131
134
  // limit-bearing headers cross the socket, and the parent throttles what it stores.
132
135
  observeProviderResponses(modelRuntime, (provider, response) => {
@@ -157,6 +160,7 @@ export async function createTerminalRuntime(args, host) {
157
160
  return sessionUiContext(host.ui, () => !disposed && generation === uiGeneration);
158
161
  };
159
162
  const planRun = { active: false, allowedTools: new Set() };
163
+ const goal = new GoalController();
160
164
  let currentMode = 'normal';
161
165
  let pendingApply = false;
162
166
  const choiceStore = {
@@ -179,6 +183,11 @@ export async function createTerminalRuntime(args, host) {
179
183
  ?? session.settingsManager.getDefaultThinkingLevel() ?? 'medium');
180
184
  };
181
185
  const applyMode = () => modeChoices.apply(native.session, currentMode, modelFor, defaultEffort(native.session));
186
+ // Pi rebuilds the active tools from the transcript, where the last recorded set may be a plan's.
187
+ const restoreNormalTools = (session) => {
188
+ const recorded = normalToolsForBranch(session.sessionManager.getBranch());
189
+ session.setActiveToolsByName(normalModeTools(recorded ?? session.getActiveToolNames()));
190
+ };
182
191
  const restoreChoice = async (session, previous) => {
183
192
  if (previous.model && (session.model?.provider !== previous.model.provider || session.model?.id !== previous.model.id)) {
184
193
  await session.setModel(previous.model, { persist: false }).catch(() => undefined);
@@ -216,7 +225,7 @@ export async function createTerminalRuntime(args, host) {
216
225
  additionalSkillPaths: prepared.skills, additionalPromptTemplatePaths: prepared.prompts,
217
226
  additionalThemePaths: prepared.themes,
218
227
  extensionFactories: [
219
- { name: 'harness-terminal-bridge', factory: pi => terminalExtension(pi, planRun, () => native.session), hidden: true },
228
+ { name: 'harness-terminal-bridge', factory: pi => terminalExtension(pi, planRun, () => native.session, goal), hidden: true },
220
229
  // A model picked in the terminal reaches Pi as this event. A restore is not a pick.
221
230
  { name: 'harness-mode-choices', factory: pi => { pi.on('model_select', event => { if (event.source !== 'restore')
222
231
  void modeChoices.record(recordingMode(), { model: `${event.model.provider}/${event.model.id}` }); }); }, hidden: true },
@@ -275,7 +284,7 @@ export async function createTerminalRuntime(args, host) {
275
284
  ...(thinkingLevel ? { thinkingLevel } : {})
276
285
  });
277
286
  installPluginCommandRouter(created.session, resourceOwners, barePrompts);
278
- created.session.setActiveToolsByName(normalModeTools(created.session.getActiveToolNames()));
287
+ restoreNormalTools(created.session);
279
288
  const services = { cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics: [] };
280
289
  host.resources?.(services);
281
290
  return { ...created, services, diagnostics: services.diagnostics };
@@ -382,6 +391,7 @@ export async function createTerminalRuntime(args, host) {
382
391
  planRun.allowedTools = new Set(allowedTools);
383
392
  planRun.active = true;
384
393
  try {
394
+ session.sessionManager.appendCustomEntry(PLAN_TOOLS_ENTRY, { phase: 'begin', normal: originalTools });
385
395
  session.setActiveToolsByName(allowedTools);
386
396
  await modeChoices.apply(session, 'plan', modelFor, defaultEffort(session));
387
397
  await session.prompt(text, { expandPromptTemplates: false, ...(images?.length ? { images } : {}) });
@@ -391,6 +401,7 @@ export async function createTerminalRuntime(args, host) {
391
401
  planRun.allowedTools = new Set();
392
402
  if (!disposed && native.session === session) {
393
403
  session.setActiveToolsByName(originalTools);
404
+ session.sessionManager.appendCustomEntry(PLAN_TOOLS_ENTRY, { phase: 'end' });
394
405
  await modeChoices.quietly(() => restoreChoice(session, previous));
395
406
  if (pendingApply) {
396
407
  pendingApply = false;
@@ -429,6 +440,8 @@ export async function createTerminalRuntime(args, host) {
429
440
  fork: (entryId, options) => exclusive(() => native.fork(entryId, options)),
430
441
  navigateTree: (targetId, options) => exclusive(async () => {
431
442
  const result = await native.session.navigateTree(targetId, options);
443
+ if (!result.cancelled && !planRun.active)
444
+ restoreNormalTools(native.session);
432
445
  if (!result.cancelled)
433
446
  await announce();
434
447
  return result;
@@ -451,7 +464,11 @@ export async function createTerminalRuntime(args, host) {
451
464
  } });
452
465
  host.changed();
453
466
  }),
467
+ goal,
454
468
  async interrupt() {
469
+ // The person stopping a running goal pauses it, as in Codex; /goal resume continues.
470
+ if (goal.active() && !native.session.isIdle)
471
+ goal.pause();
455
472
  // Bash executions use their own abort controllers, outside Pi's agent tool loop.
456
473
  native.session.abortBash();
457
474
  // Socket-backed questions and approvals must settle before Pi can finish aborting tools.
@@ -19,7 +19,7 @@ export function askUserTool(run) {
19
19
  name: ASK_USER,
20
20
  label: 'Ask the user',
21
21
  description: 'Ask the user one to five related questions in one request and wait until they answer the batch. Give each question a concise 1–2 word label and aim to keep each question to 25 words or fewer. ' +
22
- 'Every question must offer 1–6 short choices, each with a helpful description of 25 words or fewer, and at least one choice must be marked recommended: true. Recommendations are visual only: they never preselect an answer, and submitted choices keep their original labels. Mark at most one recommended choice for a single-choice question, or several when multiSelect is true. Every question also includes a free-text “Something else” answer. Set multiSelect when several can apply. When a UI diagram helps compare choices, set uiSketch: true and give every choice a detailed, bounded ASCII wireframe. Use ASCII tables and panel borders to show the affected element with relevant neighboring UI, readable labels and spacing. Keep each panel’s outer `+` corners in fixed columns, include side edges at those same columns on every content row, and run horizontal borders corner to corner; if a title sits in a top border, leave at least two dashes on both sides. Keep framing consistent across alternatives and concentrate detail on what changes. Do not substitute a symbolic logo or one-line illustration. The preview supplies its outer border; draw internal borders only for actual UI panels. Use it when you need a decision, not for small talk.',
22
+ 'Every question must offer 1–6 short choices, each with a helpful description of 25 words or fewer, and at least one choice must be marked recommended: true. Recommendations are visual only: they never preselect an answer, and submitted choices keep their original labels. Mark at most one recommended choice for a single-choice question, or several when multiSelect is true. Every question also includes a free-text “Something else” answer. Set multiSelect when several can apply. When a UI diagram helps compare choices, set uiSketch: true and give every choice a detailed, bounded ASCII wireframe. Use ASCII tables and panel borders to show the affected element with relevant neighboring UI, readable labels and spacing. Keep each panel’s outer `+` corners in fixed columns, include side edges at those same columns on every content row, and run horizontal borders corner to corner; if a title sits in a top border, leave at least two dashes on both sides. Keep framing consistent across alternatives and concentrate detail on what changes. Do not substitute a symbolic logo or one-line illustration. The preview supplies its outer border; draw internal borders only for actual UI panels. Use it when you need a decision the request and the files do not answer, not for small talk and never to ask permission for work inside the request. If no answer arrives, continue with your best judgment instead of asking again or treating the turn as blocked.',
23
23
  parameters: (Type) => Type.Object({
24
24
  questions: Type.Array(Type.Object({
25
25
  summary: Type.String({ description: 'A concise label of one or two words' }),