@xingular/harness 2.1.0 → 2.1.1

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 2.1.1 - 2026-10-19
6
+
7
+ - Change terminal `/plan` to run one isolated, read-only planning request; queue later messages until it settles and apply plan instructions only for that run.
8
+
5
9
  ## 2.1.0 - 2026-10-19
6
10
 
7
11
  - Add per-request plan mode to SDK session runs: instructions arrive as a dynamic context update, and the tool gate refuses calls outside the read-only set. The terminal uses `/plan`, `/plan on`, and `/plan off` to narrow its active tools.
package/dist/cli.js CHANGED
@@ -3624,7 +3624,7 @@ var init_askUser = __esm({
3624
3624
  var version, package_default;
3625
3625
  var init_package = __esm({
3626
3626
  "package.json"() {
3627
- version = "2.1.0";
3627
+ version = "2.1.1";
3628
3628
  package_default = {
3629
3629
  name: "@xingular/harness",
3630
3630
  version,
@@ -4920,10 +4920,8 @@ Code: ${event.userCode}`);
4920
4920
  notify(completions().map((item) => `/${item.name} ${item.description ?? ""}`).join("\n"));
4921
4921
  break;
4922
4922
  case "plan": {
4923
- const current = host.planMode();
4924
- const next = !argument || argument === "toggle" ? !current : argument === "on" ? true : argument === "off" ? false : null;
4925
- if (next === null) throw new Error("Use /plan, /plan on, /plan off, or /plan toggle.");
4926
- host.setPlanMode(next);
4923
+ if (!argument || ["on", "off", "toggle"].includes(argument.toLowerCase())) throw new Error("Use /plan <plan brief>.");
4924
+ host.plan(argument);
4927
4925
  break;
4928
4926
  }
4929
4927
  case "quit":
@@ -5144,7 +5142,7 @@ var init_commands = __esm({
5144
5142
  init_package();
5145
5143
  BUILTINS = [
5146
5144
  { name: "help", description: "Available terminal commands" },
5147
- { name: "plan", description: "Toggle read-only plan mode" },
5145
+ { name: "plan", description: "Run one read-only plan request: /plan <brief>" },
5148
5146
  { name: "new", description: "Start a new session" },
5149
5147
  { name: "resume", description: "Resume a session in this account and folder" },
5150
5148
  { name: "tree", description: "Navigate the session tree" },
@@ -6024,7 +6022,7 @@ async function withParentCancellation(signal, work) {
6024
6022
  signal?.removeEventListener("abort", stop);
6025
6023
  }
6026
6024
  }
6027
- async function piExtension_default(pi) {
6025
+ async function piExtension_default(pi, planRun) {
6028
6026
  await terminalCall("startup", { phase: "Pi extension loaded" });
6029
6027
  let context = null;
6030
6028
  let nativeSubagents = false;
@@ -6149,6 +6147,10 @@ async function piExtension_default(pi) {
6149
6147
  await controlCall("terminal.subagents.register", { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), history: subagentHistory(ctx.sessionManager.getBranch()) });
6150
6148
  subagentParent = true;
6151
6149
  }
6150
+ if (planRun && event.messageType === "user") {
6151
+ if (planRun.active) event.systemPromptOptions.sections.plan = PLAN_INSTRUCTIONS;
6152
+ else delete event.systemPromptOptions.sections.plan;
6153
+ }
6152
6154
  });
6153
6155
  const deliverChildren = async (event, ctx, settle) => {
6154
6156
  if (!nativeSubagents || settle && event.outcome !== "completed" || ctx.signal?.aborted) return;
@@ -6206,6 +6208,10 @@ async function piExtension_default(pi) {
6206
6208
  requestStartedAt = Date.now();
6207
6209
  });
6208
6210
  pi.on("tool_call", async (event) => {
6211
+ if (planRun?.active && !planRun.allowedTools.has(event.toolName)) {
6212
+ const reason = planRefusal(event.toolName, /* @__PURE__ */ new Set()) ?? `Plan mode is read-only: ${event.toolName} is not available in this session.`;
6213
+ return { block: true, reason };
6214
+ }
6209
6215
  try {
6210
6216
  const policy = await controlCall("terminal.policy", { tool: event.toolName, args: event.input });
6211
6217
  if (policy.requiresApproval) {
@@ -6290,6 +6296,15 @@ var init_piExtension = __esm({
6290
6296
  init_control();
6291
6297
  init_subagents();
6292
6298
  init_askUser();
6299
+ init_planMode();
6300
+ }
6301
+ });
6302
+
6303
+ // src/terminal/tui/planMode.ts
6304
+ var init_planMode2 = __esm({
6305
+ "src/terminal/tui/planMode.ts"() {
6306
+ "use strict";
6307
+ init_planMode();
6293
6308
  }
6294
6309
  });
6295
6310
 
@@ -6434,6 +6449,7 @@ async function createTerminalRuntime(args, host) {
6434
6449
  const generation = uiGeneration;
6435
6450
  return sessionUiContext(host.ui, () => !disposed && generation === uiGeneration);
6436
6451
  };
6452
+ const planRun = { active: false, allowedTools: /* @__PURE__ */ new Set() };
6437
6453
  const factory = async ({ cwd: targetCwd, sessionManager, sessionStartEvent }) => {
6438
6454
  if (realpathSync2(targetCwd) !== cwd) throw new Error("A native session cannot change this terminal working folder.");
6439
6455
  const settingsManager = SettingsManager.create(cwd, agentDir);
@@ -6451,7 +6467,7 @@ async function createTerminalRuntime(args, host) {
6451
6467
  additionalSkillPaths: prepared.skills,
6452
6468
  additionalPromptTemplatePaths: prepared.prompts,
6453
6469
  additionalThemePaths: prepared.themes,
6454
- extensionFactories: [...adapter2.builtins, { name: "harness-terminal-bridge", factory: piExtension_default, hidden: true }],
6470
+ extensionFactories: [...adapter2.builtins, { name: "harness-terminal-bridge", factory: (pi) => piExtension_default(pi, planRun), hidden: true }],
6455
6471
  appendSystemPromptOverride: (base) => [...base, ...prepared.appendSystemPrompt]
6456
6472
  });
6457
6473
  const reloadResources = resourceLoader.reload.bind(resourceLoader);
@@ -6560,6 +6576,25 @@ async function createTerminalRuntime(args, host) {
6560
6576
  const input = text6.trimStart().startsWith("/") ? text6.trimStart() : text6;
6561
6577
  await native.session.prompt(input, native.session.isStreaming ? { streamingBehavior: followUp ? "followUp" : "steer" } : {});
6562
6578
  },
6579
+ async submitPlan(text6) {
6580
+ if (disposed) throw new Error("The terminal runtime has closed.");
6581
+ if (changing) throw new Error("Wait for the native session change to finish before submitting.");
6582
+ if (!text6.trim()) throw new Error("Use /plan <plan brief>.");
6583
+ const session = native.session;
6584
+ if (!session.isIdle) throw new Error("A plan request must wait until the current response settles.");
6585
+ const originalTools = session.getActiveToolNames();
6586
+ const allowedTools = planModeTools(originalTools);
6587
+ planRun.allowedTools = new Set(allowedTools);
6588
+ planRun.active = true;
6589
+ try {
6590
+ session.setActiveToolsByName(allowedTools);
6591
+ await session.prompt(text6, { expandPromptTemplates: false });
6592
+ } finally {
6593
+ planRun.active = false;
6594
+ planRun.allowedTools = /* @__PURE__ */ new Set();
6595
+ if (!disposed && native.session === session) session.setActiveToolsByName(originalTools);
6596
+ }
6597
+ },
6563
6598
  get session() {
6564
6599
  return native.session;
6565
6600
  },
@@ -6679,6 +6714,7 @@ var init_runtime = __esm({
6679
6714
  "src/terminal/tui/runtime.ts"() {
6680
6715
  "use strict";
6681
6716
  init_piExtension();
6717
+ init_planMode2();
6682
6718
  init_control();
6683
6719
  init_piAdapter();
6684
6720
  init_sessionUi();
@@ -6771,14 +6807,6 @@ var init_screen = __esm({
6771
6807
  }
6772
6808
  });
6773
6809
 
6774
- // src/terminal/tui/planMode.ts
6775
- var init_planMode2 = __esm({
6776
- "src/terminal/tui/planMode.ts"() {
6777
- "use strict";
6778
- init_planMode();
6779
- }
6780
- });
6781
-
6782
6810
  // src/terminal/tui/status.ts
6783
6811
  import { basename as basename10, sep as sep7 } from "node:path";
6784
6812
  import { parseColor as parseColor2, stripTerminalSequences as stripTerminalSequences4, truncateToWidth as truncateToWidth6, visibleWidth as visibleWidth3 } from "@earendil-works/pi-tui";
@@ -7671,7 +7699,6 @@ var init_application = __esm({
7671
7699
  init_piAdapter();
7672
7700
  init_runtime();
7673
7701
  init_screen();
7674
- init_planMode2();
7675
7702
  init_status();
7676
7703
  init_skillInvocation();
7677
7704
  init_theme();
@@ -7756,8 +7783,11 @@ var init_application = __esm({
7756
7783
  phase;
7757
7784
  viewport;
7758
7785
  toolsExpanded = false;
7759
- planMode = false;
7760
- normalTools;
7786
+ deferredInputs = [];
7787
+ planBarrierOpen = false;
7788
+ planActive = false;
7789
+ drainingDeferred = false;
7790
+ activeDeferredKind;
7761
7791
  stopped = false;
7762
7792
  initialized = false;
7763
7793
  closing;
@@ -7875,8 +7905,7 @@ var init_application = __esm({
7875
7905
  this.editor.setText(text6);
7876
7906
  this.syncDraft(text6);
7877
7907
  },
7878
- planMode: () => this.planMode,
7879
- setPlanMode: (enabled) => this.setPlanMode(enabled),
7908
+ plan: (brief) => this.enqueuePlan(brief),
7880
7909
  copyText: copyToClipboard2,
7881
7910
  openExternal,
7882
7911
  changed: this.change,
@@ -7906,8 +7935,6 @@ Release notes were not bundled with this installation.`;
7906
7935
  this.keybindings.reload();
7907
7936
  this.resetWorkingStatus();
7908
7937
  this.scrollToLatest();
7909
- this.planMode = false;
7910
- this.normalTools = void 0;
7911
7938
  this.refresh(true);
7912
7939
  }));
7913
7940
  this.initialized = true;
@@ -8084,20 +8111,49 @@ Release notes were not bundled with this installation.`;
8084
8111
  this.completionStatus = void 0;
8085
8112
  this.phase = void 0;
8086
8113
  }
8087
- /** Plan mode narrows the session to read-only tools; leaving it restores the set the session had before. */
8088
- setPlanMode(enabled) {
8089
- if (!this.runtime) return;
8090
- if (this.planMode === enabled) return;
8091
- if (this.planMode) {
8092
- this.session.setActiveToolsByName(this.normalTools ?? this.session.getActiveToolNames());
8093
- this.normalTools = void 0;
8094
- } else {
8095
- this.normalTools = this.session.getActiveToolNames();
8096
- this.session.setActiveToolsByName(planModeTools(this.normalTools));
8114
+ enqueuePlan(brief) {
8115
+ const text6 = brief.trim();
8116
+ if (!text6) {
8117
+ this.notify("Use /plan <plan brief>.", "error");
8118
+ return;
8097
8119
  }
8098
- this.planMode = enabled;
8099
- this.notify(this.planMode ? "Plan mode: only read-only tools are available. Use /plan off to return to normal." : "Normal mode: all tools are available.");
8120
+ const waiting = !this.session.isIdle;
8121
+ this.planBarrierOpen = true;
8122
+ this.deferredInputs.push({ kind: "plan", text: text6 });
8123
+ this.notify(waiting ? "Plan request queued to run by itself after the current response." : "Plan request queued as an isolated run.");
8100
8124
  this.change();
8125
+ this.drainDeferred();
8126
+ }
8127
+ drainDeferred() {
8128
+ if (this.drainingDeferred || !this.planBarrierOpen || !this.runtime || !this.session.isIdle || !this.deferredInputs.length) return;
8129
+ this.drainingDeferred = true;
8130
+ void (async () => {
8131
+ try {
8132
+ while (this.deferredInputs.length && !this.stopped && this.runtime && this.session.isIdle) {
8133
+ const next = this.deferredInputs.shift();
8134
+ this.activeDeferredKind = next.kind;
8135
+ this.change();
8136
+ try {
8137
+ if (next.kind === "plan") {
8138
+ this.planActive = true;
8139
+ await this.runtime.submitPlan(next.text);
8140
+ } else {
8141
+ await this.runtime.submit(next.text, next.followUp);
8142
+ }
8143
+ } catch (error) {
8144
+ this.notify(error instanceof Error ? error.message : String(error), "error");
8145
+ } finally {
8146
+ if (next.kind === "plan") this.planActive = false;
8147
+ this.activeDeferredKind = void 0;
8148
+ }
8149
+ }
8150
+ } finally {
8151
+ this.drainingDeferred = false;
8152
+ if (!this.deferredInputs.length && !this.planActive) this.planBarrierOpen = false;
8153
+ this.change();
8154
+ if (this.deferredInputs.length && this.runtime?.session.isIdle) this.drainDeferred();
8155
+ }
8156
+ })();
8101
8157
  }
8102
8158
  scrollToLatest() {
8103
8159
  this.viewport?.scrollToEnd();
@@ -8175,7 +8231,9 @@ Release notes were not bundled with this installation.`;
8175
8231
  queuedMessages(width) {
8176
8232
  if (!this.ui) return [];
8177
8233
  const state2 = this.runtime?.snapshot();
8178
- return state2 ? [...state2.queue.steering.map((text6) => `Steering: ${text6}`), ...state2.queue.followUp.map((text6) => `Follow-up: ${text6}`)].flatMap((text6) => wrapTextWithAnsi5(this.theme.fg("muted", text6), width)) : [];
8234
+ const native = state2 ? [...state2.queue.steering.map((text6) => `Steering: ${text6}`), ...state2.queue.followUp.map((text6) => `Follow-up: ${text6}`)] : [];
8235
+ const deferred = this.deferredInputs.map((input) => input.kind === "plan" ? `Plan: /plan ${input.text}` : `Queued: ${input.text}`);
8236
+ return [...native, ...deferred].flatMap((text6) => wrapTextWithAnsi5(this.theme.fg("muted", text6), width));
8179
8237
  }
8180
8238
  statusLines(width) {
8181
8239
  return this.statuses.size ? wrapTextWithAnsi5([...this.statuses.values()].join(" "), width) : [];
@@ -8216,7 +8274,13 @@ Release notes were not bundled with this installation.`;
8216
8274
  if (this.stopped) return;
8217
8275
  if (command && await this.commands.execute(text6)) return;
8218
8276
  if (text6.trimStart().startsWith("!")) throw new Error("Use the bash tool so this action can be checked.");
8219
- await this.runtime.submit(this.commands.expandSkill(text6), followUp);
8277
+ const expanded = this.commands.expandSkill(text6);
8278
+ if (this.planBarrierOpen) {
8279
+ this.deferredInputs.push({ kind: "prompt", text: expanded, followUp });
8280
+ this.change();
8281
+ return;
8282
+ }
8283
+ await this.runtime.submit(expanded, followUp);
8220
8284
  } finally {
8221
8285
  if (command) this.commandBusy = false;
8222
8286
  }
@@ -8256,7 +8320,10 @@ Release notes were not bundled with this installation.`;
8256
8320
  } else if (is("app.message.followUp")) this.submit(this.editor.getExpandedText?.() ?? this.editor.getText(), true);
8257
8321
  else if (is("app.message.dequeue")) {
8258
8322
  const pending = this.session.clearQueue();
8259
- this.editor.setText([...pending.steering, ...pending.followUp, this.editor.getText()].filter(Boolean).join("\n"));
8323
+ const deferred = this.deferredInputs.map((input) => input.kind === "plan" ? `/plan ${input.text}` : input.text);
8324
+ this.deferredInputs = [];
8325
+ this.planBarrierOpen = this.planActive || this.activeDeferredKind === "plan";
8326
+ this.editor.setText([...pending.steering, ...pending.followUp, ...deferred, this.editor.getText()].filter(Boolean).join("\n"));
8260
8327
  this.syncDraft(this.editor.getText());
8261
8328
  } else if (is("app.editor.external")) this.attempt(() => this.externalEditor());
8262
8329
  else if (is("app.message.copy")) this.attempt(() => this.commands.execute("/copy"));
@@ -8301,6 +8368,7 @@ Release notes were not bundled with this installation.`;
8301
8368
  this.requestStartedAt = null;
8302
8369
  this.phase = void 0;
8303
8370
  this.terminal.setProgress(false);
8371
+ queueMicrotask(() => this.drainDeferred());
8304
8372
  }
8305
8373
  if (event.type === "compaction_start") this.phase = "Compacting context\u2026";
8306
8374
  if (event.type === "compaction_end") {
@@ -8338,6 +8406,10 @@ Release notes were not bundled with this installation.`;
8338
8406
  resetPresentation() {
8339
8407
  this.pendingAbort?.abort();
8340
8408
  this.dismissedDecisions.clear();
8409
+ this.deferredInputs = [];
8410
+ this.planBarrierOpen = false;
8411
+ this.planActive = false;
8412
+ this.activeDeferredKind = void 0;
8341
8413
  this.resetWorkingStatus();
8342
8414
  this.requestStartedAt = null;
8343
8415
  this.lastTokensPerSecond = null;
@@ -18,7 +18,7 @@ function connectionFromEnv(env = process.env) {
18
18
  if (!socket || !token) throw new SocketError("not_configured", `This command only works inside a terminal session the harness started (${SOCKET_ENV} and ${TOKEN_ENV} are not set).`);
19
19
  return { socket, token };
20
20
  }
21
- function request(connection, body, what, timeoutMs) {
21
+ function request(connection, body2, what, timeoutMs) {
22
22
  return new Promise((resolve, reject) => {
23
23
  const stream = createConnection(connection.socket);
24
24
  let buffer = "";
@@ -34,7 +34,7 @@ function request(connection, body, what, timeoutMs) {
34
34
  stream.destroy();
35
35
  finish(() => reject(new SocketError("timeout", `The harness did not answer ${what}.`)));
36
36
  }, timeoutMs);
37
- stream.on("connect", () => stream.write(`${JSON.stringify({ token: connection.token, ...body })}
37
+ stream.on("connect", () => stream.write(`${JSON.stringify({ token: connection.token, ...body2 })}
38
38
  `));
39
39
  stream.on("data", (chunk) => {
40
40
  buffer += chunk.toString("utf8");
@@ -177,13 +177,13 @@ async function addAccount(ui, call) {
177
177
  if (await attempt(ui, () => call("accounts.create", { label: label.trim(), authKind: "subscription" }))) ui.notify("Account added. Sign in to use it.", "info");
178
178
  }
179
179
  async function accountMenu(ui, call, session, accountId) {
180
- const isSession = session.accountId === accountId;
180
+ const isSession2 = session.accountId === accountId;
181
181
  for (; ; ) {
182
182
  const overview = await call("accounts.list");
183
183
  const account = overview.accounts.find((candidate) => candidate.id === accountId);
184
184
  if (!account) return;
185
185
  const actions = [
186
- ...isSession ? [] : [USE_HERE],
186
+ ...isSession2 ? [] : [USE_HERE],
187
187
  ...account.isDefault ? [] : [MAKE_DEFAULT],
188
188
  RENAME,
189
189
  CHECK,
@@ -193,7 +193,7 @@ async function accountMenu(ui, call, session, accountId) {
193
193
  ...account.state.piProviders.length ? [REMOVE_PROVIDER] : [],
194
194
  SIGN_OUT,
195
195
  // The session's own account is in use, so it cannot be removed from inside the session.
196
- ...isSession ? [] : [REMOVE],
196
+ ...isSession2 ? [] : [REMOVE],
197
197
  BACK
198
198
  ];
199
199
  const action = await ui.select(`${account.label} \xB7 ${HEALTH[account.state.health] ?? HEALTH.unknown}`, actions);
@@ -278,16 +278,16 @@ async function followLogin(ui, call, accountId, loginId) {
278
278
  const deadline = Date.now() + LOGIN_TIMEOUT_MS;
279
279
  let shownRevision = -1;
280
280
  for (; ; ) {
281
- const snapshot = await call("accounts.loginStatus", { accountId });
282
- if (!snapshot || snapshot.loginId !== loginId) return;
283
- if (snapshot.status !== "running") return reportLogin(ui, snapshot);
284
- if (snapshot.prompt) {
285
- await answerPrompt(ui, call, loginId, snapshot.prompt);
281
+ const snapshot2 = await call("accounts.loginStatus", { accountId });
282
+ if (!snapshot2 || snapshot2.loginId !== loginId) return;
283
+ if (snapshot2.status !== "running") return reportLogin(ui, snapshot2);
284
+ if (snapshot2.prompt) {
285
+ await answerPrompt(ui, call, loginId, snapshot2.prompt);
286
286
  continue;
287
287
  }
288
- const step = [...snapshot.events].reverse().map(loginStep).find((text3) => text3 !== null);
289
- if (step && snapshot.revision !== shownRevision) {
290
- shownRevision = snapshot.revision;
288
+ const step = [...snapshot2.events].reverse().map(loginStep).find((text3) => text3 !== null);
289
+ if (step && snapshot2.revision !== shownRevision) {
290
+ shownRevision = snapshot2.revision;
291
291
  const choice = await ui.select(`${step}
292
292
 
293
293
  Finish in your browser, then choose Check status.`, [CHECK_STATUS, CANCEL_SIGN_IN]);
@@ -306,10 +306,10 @@ Finish in your browser, then choose Check status.`, [CHECK_STATUS, CANCEL_SIGN_I
306
306
  await new Promise((resolve) => setTimeout(resolve, POLL_MS));
307
307
  }
308
308
  }
309
- function reportLogin(ui, snapshot) {
310
- if (snapshot.status === "completed") ui.notify("Signed in.", "info");
311
- else if (snapshot.status === "cancelled") ui.notify("Sign-in was cancelled.", "warning");
312
- else ui.notify(snapshot.error ?? "Sign-in failed.", "error");
309
+ function reportLogin(ui, snapshot2) {
310
+ if (snapshot2.status === "completed") ui.notify("Signed in.", "info");
311
+ else if (snapshot2.status === "cancelled") ui.notify("Sign-in was cancelled.", "warning");
312
+ else ui.notify(snapshot2.error ?? "Sign-in failed.", "error");
313
313
  }
314
314
  async function answerPrompt(ui, call, loginId, prompt) {
315
315
  const promptId = String(prompt.promptId ?? "");
@@ -838,6 +838,59 @@ function askUserTool(run2) {
838
838
  };
839
839
  }
840
840
 
841
+ // src/core/frontmatter.ts
842
+ import { parse as parseYaml, stringify } from "yaml";
843
+
844
+ // src/memory/store.ts
845
+ import { stringify as stringify2 } from "yaml";
846
+
847
+ // src/skills/registry.ts
848
+ var MAX_SKILL_IMPORT_BYTES = 10 * 1024 * 1024;
849
+
850
+ // src/prompt/compile.ts
851
+ var SECTION_ORDER = { blocks: 30, rules: 40, references: 50, skills: 60, knowledge: 80, tools: 90 };
852
+ var BUILT_IN_DYNAMIC = [
853
+ { id: "blocks", order: SECTION_ORDER.blocks },
854
+ { id: "references", order: SECTION_ORDER.references },
855
+ { id: "knowledge", order: SECTION_ORDER.knowledge }
856
+ ];
857
+
858
+ // src/sessions/planMode.ts
859
+ var PLAN_READ_ONLY_TOOLS = /* @__PURE__ */ new Set([
860
+ "read",
861
+ "grep",
862
+ "find",
863
+ "ls",
864
+ ASK_USER,
865
+ "ask_user_question",
866
+ "history_search",
867
+ "history_read",
868
+ "trajectory_search",
869
+ "trajectory_read",
870
+ "wiki_entrypoint",
871
+ "wiki_search",
872
+ "wiki_read",
873
+ "wiki_list",
874
+ "memory_facts",
875
+ "skill_load",
876
+ "mcp_list_tools"
877
+ ]);
878
+ var PLAN_OPEN_TAG = "<proposed_plan>";
879
+ var PLAN_CLOSE_TAG = "</proposed_plan>";
880
+ var PLAN_INSTRUCTIONS = `Plan mode is on for this request. Investigate and design; do not carry out the work.
881
+ - Read, search and recall freely. When a decision belongs to the user, ask with ${ASK_USER} instead of guessing.
882
+ - Do not change files, run commands, write memory, delegate, or call connected apps. Such calls are refused while plan mode is on.
883
+ - When the plan is ready, end the reply with exactly one block in this form, after any other text:
884
+ ${PLAN_OPEN_TAG}
885
+ # <Plan title>
886
+ <Markdown: the context, the steps with the files they change, and how to verify the result>
887
+ ${PLAN_CLOSE_TAG}
888
+ - If you need the user's input before a plan is possible, ask instead of proposing one.`;
889
+ function planRefusal(tool, readOnly) {
890
+ if (PLAN_READ_ONLY_TOOLS.has(tool) || readOnly.has(tool)) return null;
891
+ return `Plan mode is read-only: ${tool} is not available until the user leaves plan mode. Continue investigating, or finish with a ${PLAN_OPEN_TAG} block.`;
892
+ }
893
+
841
894
  // src/terminal/piExtension.ts
842
895
  function resultSize(result) {
843
896
  const content = result?.content;
@@ -857,7 +910,7 @@ async function withParentCancellation(signal, work) {
857
910
  signal?.removeEventListener("abort", stop);
858
911
  }
859
912
  }
860
- async function piExtension_default(pi) {
913
+ async function piExtension_default(pi, planRun) {
861
914
  await terminalCall("startup", { phase: "Pi extension loaded" });
862
915
  let context = null;
863
916
  let nativeSubagents = false;
@@ -982,6 +1035,10 @@ async function piExtension_default(pi) {
982
1035
  await controlCall("terminal.subagents.register", { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), history: subagentHistory(ctx.sessionManager.getBranch()) });
983
1036
  subagentParent = true;
984
1037
  }
1038
+ if (planRun && event.messageType === "user") {
1039
+ if (planRun.active) event.systemPromptOptions.sections.plan = PLAN_INSTRUCTIONS;
1040
+ else delete event.systemPromptOptions.sections.plan;
1041
+ }
985
1042
  });
986
1043
  const deliverChildren = async (event, ctx, settle) => {
987
1044
  if (!nativeSubagents || settle && event.outcome !== "completed" || ctx.signal?.aborted) return;
@@ -1039,6 +1096,10 @@ async function piExtension_default(pi) {
1039
1096
  requestStartedAt = Date.now();
1040
1097
  });
1041
1098
  pi.on("tool_call", async (event) => {
1099
+ if (planRun?.active && !planRun.allowedTools.has(event.toolName)) {
1100
+ const reason = planRefusal(event.toolName, /* @__PURE__ */ new Set()) ?? `Plan mode is read-only: ${event.toolName} is not available in this session.`;
1101
+ return { block: true, reason };
1102
+ }
1042
1103
  try {
1043
1104
  const policy = await controlCall("terminal.policy", { tool: event.toolName, args: event.input });
1044
1105
  if (policy.requiresApproval) {
@@ -87,22 +87,22 @@ The decision and its consequences are recorded in
87
87
 
88
88
  ## Plan mode and thinking keys
89
89
 
90
- Use `/plan`, `/plan on`, or `/plan off` to switch between normal and plan mode. The switch is
91
- announced in the transcript. Plan mode narrows the session to read-only inspection (`read`, `grep`, `find`, `ls`),
92
- questions (`ask_user`, `ask_user_question`) and recall (history and Wiki reads, `memory_facts`). Edits,
93
- writes, shell commands, memory writes, subagents and connected-app tools are withdrawn until plan mode
94
- is switched off, and switching back restores the tools the session had. Plan mode belongs to the open
95
- conversation: a new or resumed session starts in normal mode.
90
+ Use `/plan <brief>` for one read-only planning request. A plan waits outside Pi's native follow-up
91
+ queue until the active response settles, then runs by itself. Later messages remain behind it, even
92
+ when Pi's follow-up mode is `all`. During that run the session narrows to read-only inspection
93
+ (`read`, `grep`, `find`, `ls`), questions (`ask_user`, `ask_user_question`) and recall (history and
94
+ Wiki reads, `memory_facts`); edits, writes, shell commands, memory writes, subagents and connected-app
95
+ tools are blocked. The plan instructions arrive as a request-scoped prompt section, which the next
96
+ ordinary request withdraws. `/plan` without a brief and the old on/off/toggle forms are rejected.
96
97
 
97
98
  While typing, a recognized leading slash command uses the selected theme's link color. This
98
99
  includes built-in commands, loaded extension and prompt commands, and enabled skill invocations
99
100
  written as `/skill:name`. Unknown slash tokens keep the normal text color.
100
101
 
101
- SDK session runs can receive plan instructions as a dynamic context update, leaving the stable
102
- system prompt unchanged; their session gate refuses calls outside the read-only set. The standalone
103
- terminal still narrows Pi's active tools directly and does not yet deliver plan instructions through
104
- the prompt. Shift+Up and Shift+Down step the thinking level within the levels the model supports,
105
- and do not wrap.
102
+ SDK session runs receive plan instructions as a dynamic context update, leaving the stable system
103
+ prompt unchanged; their session gate refuses calls outside the read-only set. The standalone
104
+ terminal applies the same one-run instructions and tool restrictions. Shift+Up and Shift+Down step
105
+ the thinking level within the levels the model supports, and do not wrap.
106
106
 
107
107
  ## Reference and reproduced presentation
108
108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingular/harness",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "Xingular Harness — a Pi agent harness with multi-account sign-in, a compiled system prompt, a memory lifecycle (Wiki), tools, MCP, and skills",
6
6
  "repository": {
@@ -11,7 +11,8 @@ vi.mock('./client', () => ({
11
11
  terminalCall: transport.telemetry
12
12
  }))
13
13
 
14
- import terminalExtension from './piExtension'
14
+ import terminalExtension, { type TerminalPlanRunState } from './piExtension'
15
+ import { PLAN_INSTRUCTIONS } from '../sessions/planMode'
15
16
 
16
17
  function extension() {
17
18
  const handlers = new Map<string, (...args: unknown[]) => unknown>()
@@ -28,6 +29,32 @@ function extension() {
28
29
  afterEach(() => vi.useRealTimers())
29
30
 
30
31
  describe('terminal bridge lifecycle', () => {
32
+ it('adds plan instructions as a one-run context section and withdraws them on the next user request', async () => {
33
+ const state: TerminalPlanRunState = { active: true, allowedTools: new Set(['read']) }
34
+ const { api, ctx, handlers } = extension()
35
+ await terminalExtension(api, state)
36
+ const planned = { messageType: 'user', prompt: 'inspect this', systemPrompt: 'stable prefix', systemPromptOptions: { sections: { identity: 'identity' } } }
37
+ await handlers.get('before_agent_start')!(planned, ctx)
38
+ expect(planned.systemPromptOptions.sections).toEqual({ identity: 'identity', plan: PLAN_INSTRUCTIONS })
39
+ expect(planned.systemPrompt).toBe('stable prefix')
40
+
41
+ state.active = false
42
+ const ordinary = { messageType: 'user', prompt: 'now implement', systemPrompt: 'stable prefix', systemPromptOptions: { sections: { identity: 'identity', plan: PLAN_INSTRUCTIONS } } }
43
+ await handlers.get('before_agent_start')!(ordinary, ctx)
44
+ expect(ordinary.systemPromptOptions.sections).toEqual({ identity: 'identity' })
45
+ expect(ordinary.systemPrompt).toBe('stable prefix')
46
+ })
47
+
48
+ it('blocks tools outside the active plan allowlist before consulting terminal policy', async () => {
49
+ const state: TerminalPlanRunState = { active: true, allowedTools: new Set(['read']) }
50
+ const { api, ctx, handlers } = extension()
51
+ await terminalExtension(api, state)
52
+ const policyCalls = transport.socket.mock.calls.filter(([, call]) => call === 'terminal.policy').length
53
+ const result = await handlers.get('tool_call')!({ toolName: 'bash', toolCallId: 'call-1', input: { command: 'touch unsafe' } }, ctx)
54
+ expect(result).toMatchObject({ block: true, reason: expect.stringContaining('Plan mode is read-only') })
55
+ expect(transport.socket.mock.calls.filter(([, call]) => call === 'terminal.policy')).toHaveLength(policyCalls)
56
+ })
57
+
31
58
  it('keeps one private control claim while replacing native session pollers and restoring readiness', async () => {
32
59
  vi.useFakeTimers()
33
60
  const first = extension()
@@ -13,6 +13,7 @@ import { claimTerminalControl, controlCall } from './control'
13
13
  import { SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory, type SubagentReceipt } from '../sessions/subagents'
14
14
  import { askUserTool } from '../tools/askUser'
15
15
  import type { QuestionAnswer } from '../sessions/registry'
16
+ import { PLAN_INSTRUCTIONS, planRefusal } from '../sessions/planMode'
16
17
 
17
18
  interface SocketTool {
18
19
  name: string
@@ -21,6 +22,11 @@ interface SocketTool {
21
22
  parameters: Record<string, unknown>
22
23
  }
23
24
 
25
+ export interface TerminalPlanRunState {
26
+ active: boolean
27
+ allowedTools: ReadonlySet<string>
28
+ }
29
+
24
30
  /** How much text a tool handed back to the model. */
25
31
  function resultSize(result: unknown): number | null {
26
32
  const content = (result as { content?: unknown } | null | undefined)?.content
@@ -35,7 +41,7 @@ async function withParentCancellation<T>(signal: AbortSignal | undefined, work:
35
41
  try { return await work() } finally { signal?.removeEventListener('abort', stop) }
36
42
  }
37
43
 
38
- export default async function (pi: ExtensionAPI) {
44
+ export default async function (pi: ExtensionAPI, planRun?: TerminalPlanRunState) {
39
45
  await terminalCall('startup', { phase: 'Pi extension loaded' })
40
46
  let context: ExtensionContext | null = null
41
47
  let nativeSubagents = false
@@ -167,6 +173,10 @@ export default async function (pi: ExtensionAPI) {
167
173
  await controlCall('terminal.subagents.register', { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), history: subagentHistory(ctx.sessionManager.getBranch()) })
168
174
  subagentParent = true
169
175
  }
176
+ if (planRun && event.messageType === 'user') {
177
+ if (planRun.active) event.systemPromptOptions.sections.plan = PLAN_INSTRUCTIONS
178
+ else delete event.systemPromptOptions.sections.plan
179
+ }
170
180
  })
171
181
  const deliverChildren = async (event: any, ctx: ExtensionContext, settle: boolean) => {
172
182
  if (!nativeSubagents || (settle && event.outcome !== 'completed') || ctx.signal?.aborted) return
@@ -224,6 +234,10 @@ export default async function (pi: ExtensionAPI) {
224
234
  requestStartedAt = Date.now()
225
235
  })
226
236
  pi.on('tool_call', async (event) => {
237
+ if (planRun?.active && !planRun.allowedTools.has(event.toolName)) {
238
+ const reason = planRefusal(event.toolName, new Set()) ?? `Plan mode is read-only: ${event.toolName} is not available in this session.`
239
+ return { block: true, reason }
240
+ }
227
241
  try {
228
242
  const policy = await controlCall<{ allowed: boolean; reason?: string; requiresApproval?: boolean }>('terminal.policy', { tool: event.toolName, args: event.input })
229
243
  if (policy.requiresApproval) {
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
5
5
  vi.mock('../control', () => ({ controlCall: vi.fn(async () => ({})) }))
6
6
  import { controlCall } from '../control'
7
7
  import { TerminalApplication } from './application'
8
- import { createTerminalCommands, type TerminalCommandHost } from './commands'
8
+ import { createTerminalCommands } from './commands'
9
9
  import type { TerminalDecision } from './runtime'
10
10
  import { terminalKeybindings } from './keybindings'
11
11
  import { autocompleteRuleColor, createHarnessTheme } from './theme'
@@ -22,10 +22,8 @@ function setup() {
22
22
  }
23
23
  keybindings: ReturnType<typeof terminalKeybindings>
24
24
  editor: CustomEditor
25
- runtime: { interrupt: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn>; session: object; snapshot(): { hasUserMessages: boolean; cwd?: string; model?: { name: string; reasoning: boolean; contextWindow: number }; thinkingLevel?: string; account?: { label: string } | null; context?: { percent: number | null; window: number }; costUsd?: number; subscription?: boolean } }
26
- commands: ReturnType<typeof createTerminalCommands> | { execute: ReturnType<typeof vi.fn> }
27
- planMode: boolean
28
- setPlanMode(enabled: boolean): void
25
+ runtime: { interrupt: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn>; submit: ReturnType<typeof vi.fn>; submitPlan: ReturnType<typeof vi.fn>; session: object; snapshot(): { hasUserMessages: boolean; cwd?: string; model?: { name: string; reasoning: boolean; contextWindow: number }; thinkingLevel?: string; account?: { label: string } | null; context?: { percent: number | null; window: number }; costUsd?: number; subscription?: boolean } }
26
+ commands: ReturnType<typeof createTerminalCommands> | { execute: ReturnType<typeof vi.fn>; expandSkill(text: string): string }
29
27
  masthead: { render(width: number): string[] }
30
28
  header?: { render(width: number): string[]; invalidate(): void }
31
29
  branch: string | null
@@ -52,7 +50,7 @@ function setup() {
52
50
  app.keybindings = terminalKeybindings('/unused/isolated-account')
53
51
  app.initialized = true
54
52
  const context = {}
55
- app.runtime = { interrupt: vi.fn(async () => {}), dispose: vi.fn(async () => {}), snapshot: () => ({
53
+ app.runtime = { interrupt: vi.fn(async () => {}), dispose: vi.fn(async () => {}), submit: vi.fn(async () => {}), submitPlan: vi.fn(async () => {}), snapshot: () => ({
56
54
  hasUserMessages: false, cwd: '/work/project', model: { name: 'Claude Sonnet 4.6', reasoning: true, contextWindow: 200_000 },
57
55
  thinkingLevel: 'high', account: { label: 'Tanny' }, context: { percent: null, window: 0 }, costUsd: 0, subscription: false
58
56
  }), session: {
@@ -226,7 +224,7 @@ describe('the owned terminal application', () => {
226
224
  const { application, app } = setup()
227
225
  app.mountEditor()
228
226
  let resolve!: (value: boolean) => void
229
- app.commands = { execute: vi.fn(() => new Promise<boolean>(done => { resolve = done })) }
227
+ app.commands = { execute: vi.fn(() => new Promise<boolean>(done => { resolve = done })), expandSkill: text => text }
230
228
  app.submit(' /settings')
231
229
  await vi.waitFor(() => expect(app.commands.execute).toHaveBeenCalledWith(' /settings'))
232
230
  app.submit('/model')
@@ -237,35 +235,33 @@ describe('the owned terminal application', () => {
237
235
  await application.shutdown()
238
236
  })
239
237
 
240
- it('toggles plan mode with /plan, narrowing to read-only tools', async () => {
238
+ it('keeps a queued plan isolated and sends later messages after it settles', async () => {
241
239
  const { application, app } = setup()
242
- const session = app.runtime.session as { getActiveToolNames(): string[]; setActiveToolsByName(names: string[]): void; cycleThinkingLevel(): void }
243
- const setActiveToolsByName = vi.fn()
244
- const cycleThinkingLevel = vi.fn()
245
- Object.assign(session, { getActiveToolNames: () => ['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'], setActiveToolsByName, cycleThinkingLevel })
246
- app.commands = createTerminalCommands({
247
- session: () => session as never,
248
- runtime: () => ({} as never),
249
- ui: app.ui,
250
- cwd: () => '/work/project',
251
- sessionDirectory: () => '/isolated/account/history',
252
- refresh: () => {},
253
- changed: () => {},
254
- planMode: () => app.planMode,
255
- setPlanMode: enabled => app.setPlanMode(enabled),
256
- shutdown: () => {},
257
- setEditorText: () => {},
258
- copyText: async () => {},
259
- trajectory: {} as TerminalCommandHost['trajectory']
260
- })
240
+ const session = app.runtime.session as { isIdle: boolean }
241
+ Object.assign(session, { isIdle: false })
242
+ const sent: string[] = []
243
+ app.runtime.submitPlan = vi.fn(async brief => { sent.push(`plan:${brief}`) })
244
+ app.runtime.submit = vi.fn(async text => { sent.push(`prompt:${text}`) })
245
+ const enqueuePlan = (application as unknown as { enqueuePlan(brief: string): void }).enqueuePlan.bind(application)
261
246
  app.mountEditor()
262
- app.submit('/plan')
263
- await vi.waitFor(() => expect(setActiveToolsByName).toHaveBeenCalledWith(['read', 'grep']))
264
- expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'grep'])
265
- app.submit('/plan off')
266
- await vi.waitFor(() => expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent']))
267
- expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'])
268
- expect(cycleThinkingLevel).not.toHaveBeenCalled()
247
+ app.commands = {
248
+ execute: vi.fn(async text => {
249
+ if (text.trimStart().startsWith('/plan ')) { enqueuePlan(text.trimStart().slice('/plan '.length)); return true }
250
+ return false
251
+ }),
252
+ expandSkill: text => text
253
+ }
254
+
255
+ app.submit('/plan inspect the source')
256
+ await vi.waitFor(() => expect(app.commandBusy).toBe(false))
257
+ app.submit('then implement it')
258
+ await vi.waitFor(() => expect((application as unknown as { deferredInputs: unknown[] }).deferredInputs).toHaveLength(2))
259
+ expect(sent).toEqual([])
260
+
261
+ session.isIdle = true
262
+ app.accept({ type: 'agent_settled' } as AgentSessionEvent)
263
+ await vi.waitFor(() => expect(sent).toEqual(['plan:inspect the source', 'prompt:then implement it']))
264
+ expect(app.runtime.submitPlan).toHaveBeenCalledOnce()
269
265
  await application.shutdown()
270
266
  })
271
267
 
@@ -16,7 +16,6 @@ import { HARNESS_KEYBINDINGS, terminalKeybindings } from './keybindings'
16
16
  import { loadPiAdapter, setPiTheme } from './piAdapter'
17
17
  import { createTerminalRuntime, type TerminalDecision, type TerminalRuntime } from './runtime'
18
18
  import { createDock, createTerminalScreen, mountTerminalLayout, readScreenPreferences, scrollbackLines, writeScrollback, type ScreenHooks, type ScreenPreferences, type TerminalScreen } from './screen'
19
- import { planModeTools } from './planMode'
20
19
  import { renderStatusBand, singleLine } from './status'
21
20
  import { skillCompletions } from './skillInvocation'
22
21
  import { createHarnessLightTheme, createHarnessTheme } from './theme'
@@ -30,6 +29,7 @@ import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../traje
30
29
 
31
30
  const exec = promisify(execFile)
32
31
  type Mounted = Component & { dispose?(): void }
32
+ type DeferredTerminalInput = { kind: 'plan'; text: string } | { kind: 'prompt'; text: string; followUp: boolean }
33
33
  type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0]
34
34
  type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0]
35
35
  type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0]
@@ -102,8 +102,11 @@ export class TerminalApplication {
102
102
  private phase?: string
103
103
  private viewport?: ScrollView
104
104
  private toolsExpanded = false
105
- private planMode = false
106
- private normalTools?: string[]
105
+ private deferredInputs: DeferredTerminalInput[] = []
106
+ private planBarrierOpen = false
107
+ private planActive = false
108
+ private drainingDeferred = false
109
+ private activeDeferredKind?: DeferredTerminalInput['kind']
107
110
  private stopped = false
108
111
  private initialized = false
109
112
  private closing?: Promise<void>
@@ -184,7 +187,7 @@ export class TerminalApplication {
184
187
  session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
185
188
  cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
186
189
  refresh: () => this.refresh(), shutdown: () => { void this.shutdown() }, setEditorText: text => { this.editor.setText(text); this.syncDraft(text) },
187
- planMode: () => this.planMode, setPlanMode: enabled => this.setPlanMode(enabled),
190
+ plan: brief => this.enqueuePlan(brief),
188
191
  copyText: copyToClipboard, openExternal,
189
192
  changed: this.change,
190
193
  trajectory: {
@@ -202,7 +205,7 @@ export class TerminalApplication {
202
205
  })
203
206
  this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
204
207
  this.cleanup.push(this.runtime.onBeforeSessionChange(() => this.resetPresentation()))
205
- this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.planMode = false; this.normalTools = undefined; this.refresh(true) }))
208
+ this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.refresh(true) }))
206
209
  this.initialized = true
207
210
  this.phase = undefined
208
211
  this.refresh(true)
@@ -307,20 +310,46 @@ export class TerminalApplication {
307
310
  this.completionStatus = undefined
308
311
  this.phase = undefined
309
312
  }
310
- /** Plan mode narrows the session to read-only tools; leaving it restores the set the session had before. */
311
- private setPlanMode(enabled: boolean): void {
312
- if (!this.runtime) return
313
- if (this.planMode === enabled) return
314
- if (this.planMode) {
315
- this.session.setActiveToolsByName(this.normalTools ?? this.session.getActiveToolNames())
316
- this.normalTools = undefined
317
- } else {
318
- this.normalTools = this.session.getActiveToolNames()
319
- this.session.setActiveToolsByName(planModeTools(this.normalTools))
320
- }
321
- this.planMode = enabled
322
- this.notify(this.planMode ? 'Plan mode: only read-only tools are available. Use /plan off to return to normal.' : 'Normal mode: all tools are available.')
313
+ private enqueuePlan(brief: string): void {
314
+ const text = brief.trim()
315
+ if (!text) { this.notify('Use /plan <plan brief>.', 'error'); return }
316
+ const waiting = !this.session.isIdle
317
+ this.planBarrierOpen = true
318
+ this.deferredInputs.push({ kind: 'plan', text })
319
+ this.notify(waiting ? 'Plan request queued to run by itself after the current response.' : 'Plan request queued as an isolated run.')
323
320
  this.change()
321
+ this.drainDeferred()
322
+ }
323
+ private drainDeferred(): void {
324
+ if (this.drainingDeferred || !this.planBarrierOpen || !this.runtime || !this.session.isIdle || !this.deferredInputs.length) return
325
+ this.drainingDeferred = true
326
+ void (async () => {
327
+ try {
328
+ while (this.deferredInputs.length && !this.stopped && this.runtime && this.session.isIdle) {
329
+ const next = this.deferredInputs.shift()!
330
+ this.activeDeferredKind = next.kind
331
+ this.change()
332
+ try {
333
+ if (next.kind === 'plan') {
334
+ this.planActive = true
335
+ await this.runtime.submitPlan(next.text)
336
+ } else {
337
+ await this.runtime.submit(next.text, next.followUp)
338
+ }
339
+ } catch (error) {
340
+ this.notify(error instanceof Error ? error.message : String(error), 'error')
341
+ } finally {
342
+ if (next.kind === 'plan') this.planActive = false
343
+ this.activeDeferredKind = undefined
344
+ }
345
+ }
346
+ } finally {
347
+ this.drainingDeferred = false
348
+ if (!this.deferredInputs.length && !this.planActive) this.planBarrierOpen = false
349
+ this.change()
350
+ if (this.deferredInputs.length && this.runtime?.session.isIdle) this.drainDeferred()
351
+ }
352
+ })()
324
353
  }
325
354
  private scrollToLatest(): void { this.viewport?.scrollToEnd() }
326
355
  private async chooseApproval(request: TerminalDecision, signal: AbortSignal): Promise<{ decision: 'approved' | 'denied'; alwaysAllow: boolean }> {
@@ -397,7 +426,9 @@ export class TerminalApplication {
397
426
  private queuedMessages(width: number): string[] {
398
427
  if (!this.ui) return []
399
428
  const state = this.runtime?.snapshot()
400
- return state ? [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)].flatMap(text => wrapTextWithAnsi(this.theme.fg('muted', text), width)) : []
429
+ const native = state ? [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)] : []
430
+ const deferred = this.deferredInputs.map(input => input.kind === 'plan' ? `Plan: /plan ${input.text}` : `Queued: ${input.text}`)
431
+ return [...native, ...deferred].flatMap(text => wrapTextWithAnsi(this.theme.fg('muted', text), width))
401
432
  }
402
433
  private statusLines(width: number): string[] {
403
434
  return this.statuses.size ? wrapTextWithAnsi([...this.statuses.values()].join(' '), width) : []
@@ -427,7 +458,13 @@ export class TerminalApplication {
427
458
  if (this.stopped) return
428
459
  if (command && await this.commands.execute(text)) return
429
460
  if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
430
- await this.runtime!.submit(this.commands.expandSkill(text), followUp)
461
+ const expanded = this.commands.expandSkill(text)
462
+ if (this.planBarrierOpen) {
463
+ this.deferredInputs.push({ kind: 'prompt', text: expanded, followUp })
464
+ this.change()
465
+ return
466
+ }
467
+ await this.runtime!.submit(expanded, followUp)
431
468
  } finally { if (command) this.commandBusy = false }
432
469
  })
433
470
  }
@@ -455,7 +492,14 @@ export class TerminalApplication {
455
492
  else if (is('app.tools.expand')) this.ui.setToolsExpanded(!this.toolsExpanded)
456
493
  else if (is('app.thinking.toggle')) { const hidden = !this.session.settingsManager.getHideThinkingBlock(); this.session.settingsManager.setHideThinkingBlock(hidden); this.transcript.setThinking(hidden) }
457
494
  else if (is('app.message.followUp')) this.submit(this.editor.getExpandedText?.() ?? this.editor.getText(), true)
458
- else if (is('app.message.dequeue')) { const pending = this.session.clearQueue(); this.editor.setText([...pending.steering, ...pending.followUp, this.editor.getText()].filter(Boolean).join('\n')); this.syncDraft(this.editor.getText()) }
495
+ else if (is('app.message.dequeue')) {
496
+ const pending = this.session.clearQueue()
497
+ const deferred = this.deferredInputs.map(input => input.kind === 'plan' ? `/plan ${input.text}` : input.text)
498
+ this.deferredInputs = []
499
+ this.planBarrierOpen = this.planActive || this.activeDeferredKind === 'plan'
500
+ this.editor.setText([...pending.steering, ...pending.followUp, ...deferred, this.editor.getText()].filter(Boolean).join('\n'))
501
+ this.syncDraft(this.editor.getText())
502
+ }
459
503
  else if (is('app.editor.external')) this.attempt(() => this.externalEditor())
460
504
  else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
461
505
  else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
@@ -498,6 +542,7 @@ export class TerminalApplication {
498
542
  this.requestStartedAt = null
499
543
  this.phase = undefined
500
544
  this.terminal.setProgress(false)
545
+ queueMicrotask(() => this.drainDeferred())
501
546
  }
502
547
  if (event.type === 'compaction_start') this.phase = 'Compacting context…'
503
548
  if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
@@ -527,6 +572,7 @@ export class TerminalApplication {
527
572
  }
528
573
  private resetPresentation(): void {
529
574
  this.pendingAbort?.abort(); this.dismissedDecisions.clear()
575
+ this.deferredInputs = []; this.planBarrierOpen = false; this.planActive = false; this.activeDeferredKind = undefined
530
576
  this.resetWorkingStatus()
531
577
  this.requestStartedAt = null; this.lastTokensPerSecond = null
532
578
  this.transcript.clearSession()
@@ -31,7 +31,7 @@ function setup() {
31
31
  session: () => session, runtime: () => runtime as unknown as ReturnType<TerminalCommandHost['runtime']>, ui,
32
32
  cwd: () => '/isolated/project', sessionDirectory: () => '/isolated/account/history',
33
33
  refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
34
- planMode: () => false, setPlanMode: vi.fn(),
34
+ plan: vi.fn(),
35
35
  hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
36
36
  trajectory: {
37
37
  list: vi.fn(async () => [trajectoryRun]),
@@ -65,6 +65,18 @@ describe('owned terminal commands', () => {
65
65
  expect(new Set(names).size).toBe(names.length)
66
66
  })
67
67
 
68
+ it('runs one plan request from /plan <brief> and rejects toggle syntax', async () => {
69
+ const { commands, host, ui } = setup()
70
+ expect(await commands.execute('/plan inspect the current implementation')).toBe(true)
71
+ expect(host.plan).toHaveBeenCalledWith('inspect the current implementation')
72
+ expect(await commands.execute('/plan')).toBe(true)
73
+ expect(await commands.execute('/plan on')).toBe(true)
74
+ expect(await commands.execute('/plan toggle')).toBe(true)
75
+ expect(host.plan).toHaveBeenCalledOnce()
76
+ expect(ui.notify).toHaveBeenLastCalledWith('Use /plan <plan brief>.', 'error')
77
+ expect(commands.completions().find(item => item.name === 'plan')?.description).toContain('/plan <brief>')
78
+ })
79
+
68
80
  it('invokes a loaded skill as $name, and leaves other text for the SDK', () => {
69
81
  const { commands, session } = setup()
70
82
  expect(commands.skills()).toEqual([{ name: 'diagnose', description: 'Diagnose a problem' }])
@@ -21,8 +21,7 @@ export interface TerminalCommandHost {
21
21
  sessionDirectory(): string
22
22
  refresh(): void
23
23
  changed(): void
24
- planMode(): boolean
25
- setPlanMode(enabled: boolean): void
24
+ plan(brief: string): void
26
25
  shutdown(): void
27
26
  setEditorText(text: string): void
28
27
  copyText(text: string): Promise<void>
@@ -41,7 +40,7 @@ export interface TerminalCommandCompletion { name: string; description?: string
41
40
 
42
41
  const BUILTINS: TerminalCommandCompletion[] = [
43
42
  { name: 'help', description: 'Available terminal commands' },
44
- { name: 'plan', description: 'Toggle read-only plan mode' },
43
+ { name: 'plan', description: 'Run one read-only plan request: /plan <brief>' },
45
44
  { name: 'new', description: 'Start a new session' },
46
45
  { name: 'resume', description: 'Resume a session in this account and folder' },
47
46
  { name: 'tree', description: 'Navigate the session tree' },
@@ -290,10 +289,8 @@ export function createTerminalCommands(host: TerminalCommandHost) {
290
289
  switch (name) {
291
290
  case 'help': notify(completions().map(item => `/${item.name} ${item.description ?? ''}`).join('\n')); break
292
291
  case 'plan': {
293
- const current = host.planMode()
294
- const next = !argument || argument === 'toggle' ? !current : argument === 'on' ? true : argument === 'off' ? false : null
295
- if (next === null) throw new Error('Use /plan, /plan on, /plan off, or /plan toggle.')
296
- host.setPlanMode(next)
292
+ if (!argument || ['on', 'off', 'toggle'].includes(argument.toLowerCase())) throw new Error('Use /plan <plan brief>.')
293
+ host.plan(argument)
297
294
  break
298
295
  }
299
296
  case 'quit': host.shutdown(); break
@@ -7,7 +7,8 @@ import {
7
7
  import { closeSync, existsSync, openSync, readSync, realpathSync } from 'node:fs'
8
8
  import { isAbsolute, join, relative, resolve, sep } from 'node:path'
9
9
 
10
- import terminalExtension from '../piExtension'
10
+ import terminalExtension, { type TerminalPlanRunState } from '../piExtension'
11
+ import { planModeTools } from './planMode'
11
12
  import { claimTerminalControl, controlCall } from '../control'
12
13
  import { loadPiAdapter } from './piAdapter'
13
14
  import type { SessionAccount } from '../accountsMenu'
@@ -101,6 +102,7 @@ export interface TerminalRuntime {
101
102
  answerBatch(batchId: string, answers: Array<{ questionId: string; answer: QuestionAnswer }>): Promise<void>
102
103
  snapshot(): TerminalSnapshot
103
104
  submit(text: string, followUp?: boolean): Promise<void>
105
+ submitPlan(text: string): Promise<void>
104
106
  readonly session: AgentSession
105
107
  readonly services: AgentSessionServices
106
108
  readonly cwd: string
@@ -200,6 +202,7 @@ export async function createTerminalRuntime(args: string[], host: TerminalRuntim
200
202
  const generation = uiGeneration
201
203
  return sessionUiContext(host.ui, () => !disposed && generation === uiGeneration)
202
204
  }
205
+ const planRun: TerminalPlanRunState = { active: false, allowedTools: new Set() }
203
206
 
204
207
  const factory: CreateAgentSessionRuntimeFactory = async ({ cwd: targetCwd, sessionManager, sessionStartEvent }) => {
205
208
  if (realpathSync(targetCwd) !== cwd) throw new Error('A native session cannot change this terminal working folder.')
@@ -213,7 +216,7 @@ export async function createTerminalRuntime(args: string[], host: TerminalRuntim
213
216
  additionalExtensionPaths: prepared.extensions.slice(0, -1),
214
217
  additionalSkillPaths: prepared.skills, additionalPromptTemplatePaths: prepared.prompts,
215
218
  additionalThemePaths: prepared.themes,
216
- extensionFactories: [...adapter.builtins, { name: 'harness-terminal-bridge', factory: terminalExtension, hidden: true }],
219
+ extensionFactories: [...adapter.builtins, { name: 'harness-terminal-bridge', factory: pi => terminalExtension(pi, planRun), hidden: true }],
217
220
  appendSystemPromptOverride: (base) => [...base, ...prepared.appendSystemPrompt]
218
221
  })
219
222
  const reloadResources = resourceLoader.reload.bind(resourceLoader)
@@ -309,6 +312,25 @@ export async function createTerminalRuntime(args: string[], host: TerminalRuntim
309
312
  const input = text.trimStart().startsWith('/') ? text.trimStart() : text
310
313
  await native.session.prompt(input, native.session.isStreaming ? { streamingBehavior: followUp ? 'followUp' : 'steer' } : {})
311
314
  },
315
+ async submitPlan(text) {
316
+ if (disposed) throw new Error('The terminal runtime has closed.')
317
+ if (changing) throw new Error('Wait for the native session change to finish before submitting.')
318
+ if (!text.trim()) throw new Error('Use /plan <plan brief>.')
319
+ const session = native.session
320
+ if (!session.isIdle) throw new Error('A plan request must wait until the current response settles.')
321
+ const originalTools = session.getActiveToolNames()
322
+ const allowedTools = planModeTools(originalTools)
323
+ planRun.allowedTools = new Set(allowedTools)
324
+ planRun.active = true
325
+ try {
326
+ session.setActiveToolsByName(allowedTools)
327
+ await session.prompt(text, { expandPromptTemplates: false })
328
+ } finally {
329
+ planRun.active = false
330
+ planRun.allowedTools = new Set()
331
+ if (!disposed && native.session === session) session.setActiveToolsByName(originalTools)
332
+ }
333
+ },
312
334
  get session() { return native.session },
313
335
  get services() { return native.services },
314
336
  cwd, sessionDirectory,