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