@xingular/harness 1.3.1-unstable.9 → 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 (69) hide show
  1. package/CHANGELOG.md +42 -6
  2. package/NOTICE.md +26 -0
  3. package/builtin/skills/plugin-authoring/SKILL.md +20 -1
  4. package/dist/cli.js +1035 -163
  5. package/dist/sdk/commands/registry.d.ts +7 -0
  6. package/dist/sdk/commands/registry.js +1 -0
  7. package/dist/sdk/plugins/discover.js +80 -4
  8. package/dist/sdk/plugins/install.js +26 -0
  9. package/dist/sdk/plugins/manifest.d.ts +2 -0
  10. package/dist/sdk/plugins/manifest.js +20 -1
  11. package/dist/sdk/prompt/compile.d.ts +4 -3
  12. package/dist/sdk/prompt/compile.js +4 -18
  13. package/dist/sdk/prompt/reporting.d.ts +2 -0
  14. package/dist/sdk/prompt/reporting.js +282 -0
  15. package/dist/sdk/prompt/standard.d.ts +12 -0
  16. package/dist/sdk/prompt/standard.js +53 -2
  17. package/dist/sdk/sessions/completionCheck.d.ts +8 -10
  18. package/dist/sdk/sessions/completionCheck.js +21 -26
  19. package/dist/sdk/sessions/contracts.d.ts +90 -0
  20. package/dist/sdk/sessions/contracts.js +13 -0
  21. package/dist/sdk/sessions/goal.d.ts +145 -0
  22. package/dist/sdk/sessions/goal.js +405 -0
  23. package/dist/sdk/sessions/goalLoop.d.ts +37 -0
  24. package/dist/sdk/sessions/goalLoop.js +125 -0
  25. package/dist/sdk/sessions/goalPrompts.d.ts +4 -0
  26. package/dist/sdk/sessions/goalPrompts.js +93 -0
  27. package/dist/sdk/sessions/runStore.js +1 -0
  28. package/dist/sdk/sessions/runner.d.ts +7 -1
  29. package/dist/sdk/sessions/runner.js +13 -12
  30. package/dist/sdk/sessions/sessions.d.ts +10 -0
  31. package/dist/sdk/sessions/sessions.js +44 -4
  32. package/dist/sdk/sessions/stopGate.d.ts +16 -27
  33. package/dist/sdk/sessions/stopGate.js +32 -60
  34. package/dist/sdk/sessions/workSlice.d.ts +2 -0
  35. package/dist/sdk/sessions/workSlice.js +1 -1
  36. package/dist/sdk/terminal/piExtension.d.ts +2 -1
  37. package/dist/sdk/terminal/piExtension.js +68 -13
  38. package/dist/sdk/terminal/terminals.js +2 -1
  39. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  40. package/dist/sdk/terminal/tui/commands.js +3 -0
  41. package/dist/sdk/terminal/tui/goalCommand.d.ts +19 -0
  42. package/dist/sdk/terminal/tui/goalCommand.js +83 -0
  43. package/dist/sdk/terminal/tui/runtime.d.ts +3 -0
  44. package/dist/sdk/terminal/tui/runtime.js +7 -1
  45. package/dist/sdk/tools/askUser.js +1 -1
  46. package/dist/sdk/tools/todo.js +2 -1
  47. package/dist/terminal-opentui.js +839 -119
  48. package/dist/terminal-pi.js +387 -80
  49. package/docs/architecture/02-sessions-and-runs.md +28 -20
  50. package/docs/architecture/04-prompt.md +73 -52
  51. package/docs/architecture/06-plugins-and-capabilities.md +16 -1
  52. package/docs/architecture/07-tools-and-gate.md +7 -4
  53. package/docs/architecture/10-server-protocol.md +13 -6
  54. package/docs/architecture/11-terminal.md +23 -7
  55. package/docs/decisions/ADR-0008-flexible-reporting-guidance.md +3 -0
  56. package/docs/decisions/ADR-0068-scope-and-completion-report.md +5 -2
  57. package/docs/decisions/ADR-0071-completion-check.md +3 -2
  58. package/docs/decisions/ADR-0072-codex-prompts-and-goal.md +105 -0
  59. package/docs/decisions/ADR-0073-large-reporting-prompt.md +84 -0
  60. package/docs/parity.md +4 -1
  61. package/docs/releases/1.4.0-unstable.md +18 -0
  62. package/docs/releases/README.md +1 -1
  63. package/docs/study/00-index.md +7 -0
  64. package/docs/study/STUDY-0004-turn-end-controls.md +2 -0
  65. package/docs/study/STUDY-0005-skill-installation-guidance.md +146 -0
  66. package/docs/study/STUDY-0006-reporting-prompts.md +81 -0
  67. package/docs/study/arms/04-skills.md +6 -0
  68. package/docs/terminal-tui.md +17 -7
  69. package/package.json +1 -1
@@ -14,6 +14,8 @@ import { askUserTool } from "../tools/askUser.js";
14
14
  import { TODO_WRITE, todoTool } from "../tools/todo.js";
15
15
  import { createStopGateState, handoffFields, sessionCheckModel, stopCheckpointReason, stopGate, stopGateNotice, stopOutcome, stopSummary, STOP_GATE_EXTENSION } from "../sessions/stopGate.js";
16
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";
17
19
  import { createSliceState, DEFAULT_SLICE_POLICY, parseWorkSlice, sliceContinuation, workSliceGuard } from "../sessions/workSlice.js";
18
20
  import { DEFAULT_COMMAND_TIMEOUT_SECONDS, withCommandBudget } from "../core/commandBudget.js";
19
21
  import { SESSION_NAME, deriveSessionName, sessionNameTool } from "../tools/sessionName.js";
@@ -50,7 +52,7 @@ async function withParentCancellation(signal, work) {
50
52
  signal?.removeEventListener('abort', stop);
51
53
  }
52
54
  }
53
- export default async function (pi, planRun, getSession) {
55
+ export default async function (pi, planRun, getSession, goal) {
54
56
  await terminalCall('startup', { phase: 'Pi extension loaded' });
55
57
  let context = null;
56
58
  let nativeSubagents = false;
@@ -190,8 +192,8 @@ export default async function (pi, planRun, getSession) {
190
192
  // `XINGULAR_WORK_SLICE=off` at launch leaves terminal replies unbounded, as before PROP-0041.
191
193
  // One stop-gate state per terminal session; each reply resets it.
192
194
  const gateState = createStopGateState();
193
- // A completion check's model request counts with the reply's usage, like any other request.
194
- gateState.recordUsage = (raw) => {
195
+ // A goal-claim check's model request counts with the reply's usage, like any other request.
196
+ const recordCheckUsage = (raw) => {
195
197
  const message = raw;
196
198
  const reported = message.usage;
197
199
  if (!reported)
@@ -488,20 +490,72 @@ export default async function (pi, planRun, getSession) {
488
490
  continue: true
489
491
  };
490
492
  });
491
- // The stop gate (ADR-0069, ADR-0071), after subagent delivery and plan repair so it sees their
492
- // continuations: a terminal reply that ends with open todo items, or that the completion check finds
493
- // unfinished, is sent back up to its cap. Plan replies are not gated.
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.
494
496
  if (!launchSwitchOff('XINGULAR_STOP_GATE'))
495
497
  stopGate({
496
498
  state: gateState,
497
499
  active: () => !planRun?.active,
498
500
  sliceEnded: () => sliceState.endedBy !== null,
499
501
  failed: () => Boolean(failure),
500
- check: launchSwitchOff('XINGULAR_COMPLETION_CHECK') ? null : sessionCheckModel,
501
502
  onEvent: (event) => {
502
503
  void terminalCall('notice', { kind: 'notify', level: 'info', extension: STOP_GATE_EXTENSION, text: stopGateNotice(event) }).catch(() => undefined);
503
504
  }
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
+ }
505
559
  // After the stop gate, which stays silent at a slice boundary: a reply that did work and reached
506
560
  // its budget continues as the next slice of the same run (ADR-0070).
507
561
  if (slicePolicy.enabled)
@@ -510,11 +564,12 @@ export default async function (pi, planRun, getSession) {
510
564
  state: sliceState,
511
565
  active: () => !planRun?.active,
512
566
  failed: () => Boolean(failure),
513
- blocked: () => gateState.verdict?.verdict === 'needs_person',
567
+ blocked: () => goal?.get()?.status === 'blocked',
568
+ goalActive: () => goal?.active() === true,
514
569
  todo: () => gateState.todo,
515
- next: () => handoffFields(gateState).next,
570
+ next: () => handoffFields(gateState, goal?.get() ?? null).next,
516
571
  onContinue: (ended) => {
517
- void terminalCall('checkpoint', { summary: `Work slice ${ended.slice} reached its limit after ${ended.steps} steps; the reply continues.`, ...handoffFields(gateState), files: [], checks: [], decisions: [], slice: ended.slice, reason: 'budget' }).catch(() => undefined);
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);
518
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);
519
574
  }
520
575
  })(pi);
@@ -725,13 +780,13 @@ export default async function (pi, planRun, getSession) {
725
780
  // A reply that ended with open todo items is not completed either (ADR-0069): it stopped for the
726
781
  // person, or the stop gate's nudges ran out. The harness writes its checkpoint from the todo list.
727
782
  const aborted = event.aborted || messageAborted;
728
- const stop = !sliceState.endedBy && !failure && !aborted && !planRun?.active ? stopOutcome(gateState) : null;
783
+ const stop = !sliceState.endedBy && !failure && !aborted && !planRun?.active ? stopOutcome(gateState, goal?.get() ?? null) : null;
729
784
  if (stop) {
730
- await terminalCall('checkpoint', { summary: stopSummary(stop), ...handoffFields(gateState), files: [], checks: [], decisions: [], slice: sliceState.slice, reason: stopCheckpointReason(stop) }).catch(() => undefined);
785
+ await terminalCall('checkpoint', { summary: stopSummary(stop), ...handoffFields(gateState, goal?.get() ?? null), files: [], checks: [], decisions: [], slice: sliceState.slice, reason: stopCheckpointReason(stop) }).catch(() => undefined);
731
786
  }
732
787
  else if (sliceState.endedBy && !failure && !aborted) {
733
788
  // The guard ended the reply and promised the model the harness would keep its state.
734
- await terminalCall('checkpoint', { summary: `The reply stopped after ${sliceState.steps} steps at its work-slice boundary.`, ...handoffFields(gateState), files: [], checks: [], decisions: [], slice: sliceState.slice, reason: sliceState.endedBy }).catch(() => undefined);
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);
735
790
  }
736
791
  const reason = sliceState.endedBy ? 'slice_complete' : stop?.code;
737
792
  const code = sliceState.endedBy === 'compaction-thrash' ? 'compaction_thrash' : sliceState.endedBy === 'budget' ? 'slice_budget' : stop?.code;
@@ -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";
@@ -880,7 +881,7 @@ export class Terminals {
880
881
  }
881
882
  if (inspection.outside)
882
883
  return { allowed: false, requiresApproval: true, rememberable: false, standing: 'switch', reason: inspection.reason, workspaceRoot };
883
- 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)) {
884
885
  return { allowed: true, workspaceRoot };
885
886
  }
886
887
  return { allowed: false, requiresApproval: true, rememberable: true, reason: 'This native action requires approval.', workspaceRoot };
@@ -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,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,6 +2,7 @@ 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 { GoalController } from "../../sessions/goal.js";
5
6
  import { terminalCredentialStore } from "../credentialStore.js";
6
7
  import { normalModeTools, normalToolsForBranch, planModeTools, PLAN_TOOLS_ENTRY, QUESTION_TOOL_NAMES } from "./planMode.js";
7
8
  import { claimTerminalControl, controlCall } from "../control.js";
@@ -159,6 +160,7 @@ export async function createTerminalRuntime(args, host) {
159
160
  return sessionUiContext(host.ui, () => !disposed && generation === uiGeneration);
160
161
  };
161
162
  const planRun = { active: false, allowedTools: new Set() };
163
+ const goal = new GoalController();
162
164
  let currentMode = 'normal';
163
165
  let pendingApply = false;
164
166
  const choiceStore = {
@@ -223,7 +225,7 @@ export async function createTerminalRuntime(args, host) {
223
225
  additionalSkillPaths: prepared.skills, additionalPromptTemplatePaths: prepared.prompts,
224
226
  additionalThemePaths: prepared.themes,
225
227
  extensionFactories: [
226
- { 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 },
227
229
  // A model picked in the terminal reaches Pi as this event. A restore is not a pick.
228
230
  { name: 'harness-mode-choices', factory: pi => { pi.on('model_select', event => { if (event.source !== 'restore')
229
231
  void modeChoices.record(recordingMode(), { model: `${event.model.provider}/${event.model.id}` }); }); }, hidden: true },
@@ -462,7 +464,11 @@ export async function createTerminalRuntime(args, host) {
462
464
  } });
463
465
  host.changed();
464
466
  }),
467
+ goal,
465
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();
466
472
  // Bash executions use their own abort controllers, outside Pi's agent tool loop.
467
473
  native.session.abortBash();
468
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' }),
@@ -4,7 +4,8 @@ import { todoItemSchema, MAX_TODO_ITEMS } from "../sessions/contracts.js";
4
4
  export const TODO_WRITE = 'todo_write';
5
5
  /** The trigger the prompt and the tool description share, so progress guidance cannot drift. */
6
6
  export const TODO_GUIDANCE = 'If the work has several distinct steps or will take more than one tool call, call todo_write before your first other tool call with the steps you know so far, and keep the list current as steps start and finish. ' +
7
- 'Keep the list true at all times: mark a step completed only once it is done and checked, and add steps you discover. An open step is work to do in this reply, not something to report; never mark unfinished work completed. ' +
7
+ 'The list shows the person your progress; it is not the scope of the work. The request, and any document it points to, is the scope: add steps as the work shows them, list the steps of a handoff or plan instead of folding them into one "remaining work" item, and never stop because the list is short or done. ' +
8
+ 'Keep the list true at all times: mark a step completed only once it is done and checked, and never mark unfinished work completed. An open step is work to do in this reply, not something to report. Before you end the turn, compare the request, not the list, with what is done and checked, and do whatever is still yours to do. ' +
8
9
  'The person follows your work from it, and a subagent’s todo list shows as its progress in the person’s list of subagents.';
9
10
  /** The event a run writes; `checklist.updated` is the pre-rename name, still read for old runs. */
10
11
  const TODO_EVENTS = new Set(['todo.updated', 'checklist.updated']);