@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 +4 -0
- package/dist/cli.js +110 -38
- package/dist/terminal-pi.js +79 -18
- package/docs/terminal-tui.md +11 -11
- package/package.json +1 -1
- package/src/terminal/piExtension.lifecycle.test.ts +28 -1
- package/src/terminal/piExtension.ts +15 -1
- package/src/terminal/tui/application.test.ts +30 -34
- package/src/terminal/tui/application.ts +67 -21
- package/src/terminal/tui/commands.test.ts +13 -1
- package/src/terminal/tui/commands.ts +4 -7
- package/src/terminal/tui/runtime.ts +24 -2
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.
|
|
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
|
-
|
|
4924
|
-
|
|
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: "
|
|
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
|
-
|
|
7760
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8088
|
-
|
|
8089
|
-
if (!
|
|
8090
|
-
|
|
8091
|
-
|
|
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
|
-
|
|
8099
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/terminal-pi.js
CHANGED
|
@@ -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,
|
|
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, ...
|
|
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
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
|
282
|
-
if (!
|
|
283
|
-
if (
|
|
284
|
-
if (
|
|
285
|
-
await answerPrompt(ui, call, loginId,
|
|
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 = [...
|
|
289
|
-
if (step &&
|
|
290
|
-
shownRevision =
|
|
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,
|
|
310
|
-
if (
|
|
311
|
-
else if (
|
|
312
|
-
else ui.notify(
|
|
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) {
|
package/docs/terminal-tui.md
CHANGED
|
@@ -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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
terminal
|
|
104
|
-
the
|
|
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.
|
|
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
|
|
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('
|
|
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 {
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
app.
|
|
247
|
-
|
|
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.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
106
|
-
private
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
if (!this.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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')) {
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
294
|
-
|
|
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,
|