@signalridge/pi-goal 1.2.0 → 1.3.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 +30 -0
- package/README.md +5 -4
- package/package.json +6 -6
- package/src/goal.ts +9 -0
- package/src/runtime.ts +46 -1
- package/src/settings.ts +8 -1
- package/src/tool-policy.ts +2 -1
- package/src/tools.ts +110 -0
- package/src/wait.ts +112 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @signalridge/pi-goal
|
|
2
2
|
|
|
3
|
+
## 1.3.1
|
|
4
|
+
### Patch Changes
|
|
5
|
+
|
|
6
|
+
- f714ea0: Publish the package versions already prepared by the previous release transition after its first publish attempt was blocked before npm publication.
|
|
7
|
+
|
|
8
|
+
## 1.3.0
|
|
9
|
+
### Minor Changes
|
|
10
|
+
|
|
11
|
+
- b6cf242: New `goal_wait` tool: a goal that depends on something outside the session — CI finishing, a review landing, a reply arriving — can now say so. Without it the model either burns continuation turns re-checking or reports a blocker that is not one; `goal_wait` records *why* it is waiting, pauses the goal, and either waits for the next real message or wakes itself on a deadline.
|
|
12
|
+
|
|
13
|
+
It reuses the existing `paused` state rather than adding a fourth one, so every resume path, restore path, and status surface already handles it, and the deadline resumes through the same code as `/goal resume` (tool-policy preparation, recovery clearing, prompt delivery) rather than a second, lesser resume. Requests below 10s are clamped rather than refused — the model's intent ("wait, then check") is right even when its number makes a polling loop — and the reply says when a number was raised. The wake-up re-checks at fire time that the goal is still the paused, active one, and a generation counter keeps an already-in-flight callback from waking a goal that was cleared microseconds earlier.
|
|
14
|
+
|
|
15
|
+
`toolVisibility` now defaults to `"after-first-goal"` instead of `"always"`.
|
|
16
|
+
|
|
17
|
+
`goal_complete` and `goal_blocked` are meaningless without an active goal, so in a session that never runs `/goal` — the overwhelming majority — their definitions and prompt guidelines were pure context cost paid on every request. They are now withheld until the first accepted `/goal` activation, and a session that restores an unfinished goal reveals them at startup as before.
|
|
18
|
+
|
|
19
|
+
Nothing else changes: the `"after-first-goal"` machinery is unchanged, missing and invalid settings both land on the new default (an unreadable settings file must not be a way to get the always-on behaviour), and `"always"` remains available for anyone who wants a tool schema that is identical from session startup.
|
|
20
|
+
|
|
21
|
+
Reload Pi after changing the setting, as before.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- b6cf242: Peer dependency ranges now name the versions actually validated against, so an untested host combination fails at install time instead of silently at runtime: `@earendil-works/pi-coding-agent`, `pi-ai`, `pi-tui`, and `pi-agent-core` move from `"*"` to `^0.84.0`, and `typebox` from `"*"` to `^1.3.11`.
|
|
26
|
+
|
|
27
|
+
Shared dependencies now carry ONE declared range across every package that uses them, and `bun run check:shared-deps` keeps it that way.
|
|
28
|
+
|
|
29
|
+
`@narumitw/pi-tui-kit` was declared at three disjoint floors — `^0.54.0`, `^0.51.0`, and `^0.49.1` across nine packages — and the lockfile duly resolved three copies (0.54.0, 0.51.0, 0.49.3) installed side by side. For a shared rendering surface drawing into one terminal inside one host process, that means a theme rendering one way in one extension and another way in the next, with nothing failing at install to say so. All nine now declare `^0.54.0` and the install resolves a single copy. `@sinclair/typebox` likewise converges on `^0.34.50`.
|
|
30
|
+
|
|
31
|
+
The new check covers `dependencies` and `peerDependencies` and compares range strings rather than their semantics: two ranges that merely overlap are still a finding, because the goal is one intentional answer per dependency rather than an accidental intersection. `devDependencies` are deliberately out of scope — a build tool is not a shared surface, and `pi-subagents` intentionally carries its own toolchain. `docs/package-boundaries.md` documents the Kit as a shared surface for the first time.
|
|
32
|
+
|
|
3
33
|
## 1.0.0
|
|
4
34
|
### Major Changes
|
|
5
35
|
|
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@ Goal mode uses Codex-like persistence instructions and sends guarded continuatio
|
|
|
22
22
|
- Stores goal state in the current Pi session, following Codex's thread-owned goal model instead of using a global per-directory goal. Experimental queues keep independent budget, usage, elapsed-time, iteration, status, and stale-id accounting for every item.
|
|
23
23
|
- Registers a `goal_complete({ goal_id, summary })` tool for explicit completion, requiring the current goal id and rejecting missing/stale ids plus plainly contradictory summaries such as “not complete” or “tests still fail”.
|
|
24
24
|
- Registers `goal_blocked({ goal_id, reason, evidence, repeated_turns })` for true impasses only; it requires the current goal id, concrete evidence, and the same blocker recurring for at least three consecutive goal turns.
|
|
25
|
-
-
|
|
25
|
+
- Registers `goal_wait({ goal_id, reason, resume_after_ms? })` for a goal that is waiting on something outside the session rather than unfinished or blocked. The goal pauses with the reason recorded and either waits for the next real message or wakes on the deadline; requests under 10s are clamped, since a shorter "wait" is a polling loop. Resuming a deadline uses the same path as `/goal resume`.
|
|
26
|
+
- Hides all three goal tools by default until the first goal exists (`"after-first-goal"`), keeping their definitions and prompt guidelines out of a session that never runs `/goal`; optional `"always"` visibility keeps them resident for a stable tool schema. Hidden means hidden until the first accepted `/goal` activation or an unfinished goal is restored, then keeps them desired for the rest of that extension runtime without overriding a restrictive restore policy.
|
|
26
27
|
- Records continuation and queue-transition intent, then triggers exactly one next turn only after Pi reports the agent fully settled, idle, and free of pending messages; if terminal tools disappear during a tool loop or before the next queued goal starts, pauses before another model turn.
|
|
27
28
|
- Lets retry, compaction, steering, follow-up, and other queued work settle before automatic goal continuation.
|
|
28
29
|
- Separates user interruption (`paused`), true impasse or terminal non-usage error (`blocked`), provider/account quota exhaustion (`usage_limited`), and user token budget exhaustion (`budget_limited`).
|
|
@@ -58,7 +59,7 @@ built-in defaults without creating the file:
|
|
|
58
59
|
|
|
59
60
|
```json
|
|
60
61
|
{
|
|
61
|
-
"toolVisibility": "
|
|
62
|
+
"toolVisibility": "after-first-goal",
|
|
62
63
|
"experimental": {
|
|
63
64
|
"goals": false
|
|
64
65
|
},
|
|
@@ -86,8 +87,8 @@ Custom number inputs reject zero, negative numbers, decimals, text, and unsafe i
|
|
|
86
87
|
|
|
87
88
|
`toolVisibility` accepts:
|
|
88
89
|
|
|
89
|
-
- `"always"`
|
|
90
|
-
- `"after-first-goal"` — hides both tools at fresh runtime startup, reveals them for the first accepted Goal activation, and treats an unfinished-goal restore as unlocked for the remainder of that extension runtime. On restore, pi-goal uses the active tools already established by earlier lifecycle handlers; it does not re-add missing terminal tools over a restrictive policy. Failed kickoff, replacement, resume, or reactivating-edit delivery restores the exact pre-activation tool set, including terminal tools exposed by another extension. If revealing the tools would widen an already-running turn, wait for Pi to become idle and retry `/goal`.
|
|
90
|
+
- `"always"` — pi-goal does not proactively hide `goal_complete` or `goal_blocked`, keeping the tool schema stable from session startup.
|
|
91
|
+
- `"after-first-goal"` (default) — hides both tools at fresh runtime startup, reveals them for the first accepted Goal activation, and treats an unfinished-goal restore as unlocked for the remainder of that extension runtime. On restore, pi-goal uses the active tools already established by earlier lifecycle handlers; it does not re-add missing terminal tools over a restrictive policy. Failed kickoff, replacement, resume, or reactivating-edit delivery restores the exact pre-activation tool set, including terminal tools exposed by another extension. If revealing the tools would widen an already-running turn, wait for Pi to become idle and retry `/goal`.
|
|
91
92
|
|
|
92
93
|
`experimental.goals` accepts a boolean and defaults to `false`. Set it to `true` to enable the ordered-goal subcommands and automatic queue advancement described below. Enabled sessions show one warning because command behavior and persisted queue state remain experimental.
|
|
93
94
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-goal",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Pi extension for autonomous /goal completion with opt-in experimental ordered queues.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
"lint": "biome check ."
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@earendil-works/pi-ai": "
|
|
40
|
-
"@earendil-works/pi-coding-agent": "
|
|
41
|
-
"@earendil-works/pi-tui": "
|
|
42
|
-
"typebox": "
|
|
39
|
+
"@earendil-works/pi-ai": "^0.84.0",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
41
|
+
"@earendil-works/pi-tui": "^0.84.0",
|
|
42
|
+
"typebox": "^1.3.11"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@biomejs/biome": "2.5.7",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"typescript": "7.0.2"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@narumitw/pi-tui-kit": "^0.
|
|
53
|
+
"@narumitw/pi-tui-kit": "^0.54.0"
|
|
54
54
|
},
|
|
55
55
|
"repository": {
|
|
56
56
|
"type": "git",
|
package/src/goal.ts
CHANGED
|
@@ -15,6 +15,15 @@ function registerGoalRuntime(pi: ExtensionAPI, options: GoalOptions = {}) {
|
|
|
15
15
|
const commands = new GoalCommandController(runtime);
|
|
16
16
|
const runController = new GoalRunController(runtime, commands);
|
|
17
17
|
|
|
18
|
+
// A `goal_wait` deadline resumes through the SAME path as `/goal resume`:
|
|
19
|
+
// tool-policy preparation, recovery clearing and prompt delivery all have to
|
|
20
|
+
// happen, and a shortcut would be a second, worse resume path drifting from
|
|
21
|
+
// the real one. Wired here because the controller owns it and the runtime,
|
|
22
|
+
// which arms the timer, deliberately does not depend on the controller.
|
|
23
|
+
runtime.onGoalWaitElapsed = (ctx) => {
|
|
24
|
+
void commands.resumeGoal(ctx);
|
|
25
|
+
};
|
|
26
|
+
|
|
18
27
|
// Keep registration order explicit: managed-run bus listeners exist before tools,
|
|
19
28
|
// command routing, and session lifecycle bind the per-factory runtime.
|
|
20
29
|
runController.register(pi);
|
package/src/runtime.ts
CHANGED
|
@@ -17,8 +17,9 @@ export { queueGoalSafetyReset, resetGoalSafetyEpoch } from "./safety.js";
|
|
|
17
17
|
|
|
18
18
|
import { DEFAULT_GOAL_SETTINGS, type GoalSettings, type GoalSettingsLoadIssue } from "./settings.js";
|
|
19
19
|
import { GoalToolPolicy, type GoalToolVisibilitySnapshot } from "./tool-policy.js";
|
|
20
|
+
import { GoalWaitTimer } from "./wait.js";
|
|
20
21
|
|
|
21
|
-
export { GOAL_BLOCKED_TOOL, GOAL_COMPLETE_TOOL, GOAL_TOOL_NAMES } from "./tool-policy.js";
|
|
22
|
+
export { GOAL_BLOCKED_TOOL, GOAL_COMPLETE_TOOL, GOAL_TOOL_NAMES, GOAL_WAIT_TOOL } from "./tool-policy.js";
|
|
22
23
|
|
|
23
24
|
export interface ContinuationTicket {
|
|
24
25
|
goalId: string;
|
|
@@ -62,6 +63,13 @@ export type GoalStopRequest =
|
|
|
62
63
|
reason: string;
|
|
63
64
|
}
|
|
64
65
|
| { kind: "retry_exhausted"; expectedGoalId: string; reason: string }
|
|
66
|
+
/**
|
|
67
|
+
* `goal_wait`: the goal has no work left until something outside the session
|
|
68
|
+
* happens. Stops exactly like a reported blocker — same usage accounting,
|
|
69
|
+
* same stale-call block — but lands on `paused`, which is already the state
|
|
70
|
+
* every resume path knows how to bring back.
|
|
71
|
+
*/
|
|
72
|
+
| { kind: "wait"; expectedGoalId: string; reason: string }
|
|
65
73
|
| {
|
|
66
74
|
kind: "tools_unavailable";
|
|
67
75
|
expectedGoalId: string;
|
|
@@ -184,6 +192,8 @@ export class GoalRuntime {
|
|
|
184
192
|
settings: GoalSettings = DEFAULT_GOAL_SETTINGS;
|
|
185
193
|
settingsLoadIssue?: GoalSettingsLoadIssue;
|
|
186
194
|
activeGoal?: ActiveGoal;
|
|
195
|
+
/** The single `goal_wait` safety wake-up. Cancelled with continuation work. */
|
|
196
|
+
private readonly goalWaitTimer = new GoalWaitTimer();
|
|
187
197
|
/** Terminal details captured for the matching persisted-state snapshot. */
|
|
188
198
|
private terminalDetails?: GoalTerminalDetails;
|
|
189
199
|
private goalStateSink?: (snapshot: GoalStateSnapshot) => void;
|
|
@@ -401,6 +411,15 @@ export class GoalRuntime {
|
|
|
401
411
|
status = "paused";
|
|
402
412
|
terminalReason = request.reason;
|
|
403
413
|
break;
|
|
414
|
+
case "wait":
|
|
415
|
+
this.recordGoalUsage(goal, ctx);
|
|
416
|
+
this.cancelContinuationWork();
|
|
417
|
+
this.clearGoalRecoveryForGoal(goal.id);
|
|
418
|
+
this.clearBudgetWrapUp();
|
|
419
|
+
this.blockStaleGoalToolCalls();
|
|
420
|
+
status = "paused";
|
|
421
|
+
terminalReason = request.reason;
|
|
422
|
+
break;
|
|
404
423
|
case "retry_exhausted":
|
|
405
424
|
this.clearGoalRecoveryForGoal(goal.id);
|
|
406
425
|
this.cancelContinuationWork();
|
|
@@ -706,7 +725,33 @@ export class GoalRuntime {
|
|
|
706
725
|
return true;
|
|
707
726
|
}
|
|
708
727
|
|
|
728
|
+
/**
|
|
729
|
+
* Arm the `goal_wait` safety wake-up.
|
|
730
|
+
*
|
|
731
|
+
* The callback is supplied by the command layer, which owns the only correct
|
|
732
|
+
* resume: tool-policy preparation, recovery clearing, and prompt delivery all
|
|
733
|
+
* have to happen, and a shortcut here would be a second, worse resume path.
|
|
734
|
+
* Unset (a headless or partially-built runtime) simply means a wait keeps
|
|
735
|
+
* waiting for real input, which is the safe direction.
|
|
736
|
+
*/
|
|
737
|
+
onGoalWaitElapsed?: (ctx: StatusContext, goalId: string) => void;
|
|
738
|
+
|
|
739
|
+
scheduleGoalWaitWake(ctx: StatusContext, goalId: string, resumeAt: number): void {
|
|
740
|
+
this.goalWaitTimer.schedule(resumeAt, () => {
|
|
741
|
+
// Re-checked at fire time, not at schedule time: the goal may have been
|
|
742
|
+
// completed, cleared, or replaced during the wait, and waking a goal that
|
|
743
|
+
// is no longer the active one would resume the wrong work.
|
|
744
|
+
if (this.activeGoal?.id !== goalId || this.activeGoal.status !== "paused") return;
|
|
745
|
+
this.onGoalWaitElapsed?.(ctx, goalId);
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
clearGoalWaitWake(): void {
|
|
750
|
+
this.goalWaitTimer.clear();
|
|
751
|
+
}
|
|
752
|
+
|
|
709
753
|
cancelContinuationWork() {
|
|
754
|
+
this.clearGoalWaitWake();
|
|
710
755
|
this.clearContinuationDispatchTimer();
|
|
711
756
|
if (this.continuationDelivery) {
|
|
712
757
|
this.rememberCancelledContinuationMarker(this.continuationDelivery.marker);
|
package/src/settings.ts
CHANGED
|
@@ -24,7 +24,14 @@ export interface GoalSettings {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export const DEFAULT_GOAL_SETTINGS: GoalSettings = {
|
|
27
|
-
|
|
27
|
+
// `after-first-goal`, not `always`: `goal_complete` and `goal_blocked` are
|
|
28
|
+
// meaningless without an active goal, so in a session that never runs
|
|
29
|
+
// `/goal` — the overwhelming majority — their definitions and prompt
|
|
30
|
+
// guidelines are pure context cost paid on every request. They are revealed
|
|
31
|
+
// the moment the first goal is set, and a session that restores an
|
|
32
|
+
// unfinished goal reveals them at startup. Set `"always"` to keep them
|
|
33
|
+
// resident.
|
|
34
|
+
toolVisibility: "after-first-goal",
|
|
28
35
|
experimental: { goals: false },
|
|
29
36
|
rpc: { enabled: false },
|
|
30
37
|
continuationLimits: { automaticTurns: 25, noProgressTurns: 3 },
|
package/src/tool-policy.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { GoalToolVisibility } from "./settings.js";
|
|
|
3
3
|
|
|
4
4
|
export const GOAL_COMPLETE_TOOL = "goal_complete";
|
|
5
5
|
export const GOAL_BLOCKED_TOOL = "goal_blocked";
|
|
6
|
-
export const
|
|
6
|
+
export const GOAL_WAIT_TOOL = "goal_wait";
|
|
7
|
+
export const GOAL_TOOL_NAMES = [GOAL_COMPLETE_TOOL, GOAL_BLOCKED_TOOL, GOAL_WAIT_TOOL] as const;
|
|
7
8
|
|
|
8
9
|
export interface GoalToolVisibilitySnapshot {
|
|
9
10
|
activeTools: string[];
|
package/src/tools.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
formatStatus,
|
|
12
12
|
GOAL_BLOCKED_TOOL,
|
|
13
13
|
GOAL_COMPLETE_TOOL,
|
|
14
|
+
GOAL_WAIT_TOOL,
|
|
14
15
|
type GoalRuntime,
|
|
15
16
|
goalIdRejectionReason,
|
|
16
17
|
isContradictoryCompletionSummary,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
transitionGoal,
|
|
20
21
|
truncateNotification,
|
|
21
22
|
} from "./runtime.js";
|
|
23
|
+
import { createGoalWait, MAX_GOAL_WAIT_REASON_LENGTH, MIN_GOAL_WAIT_DELAY_MS, resolveGoalWaitDelay } from "./wait.js";
|
|
22
24
|
|
|
23
25
|
interface GoalCompleteDetails {
|
|
24
26
|
goal: string;
|
|
@@ -26,6 +28,15 @@ interface GoalCompleteDetails {
|
|
|
26
28
|
summary: string;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
interface GoalWaitDetails {
|
|
32
|
+
goal: string;
|
|
33
|
+
goal_id: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
requested_resume_after_ms?: number;
|
|
36
|
+
effective_resume_after_ms?: number;
|
|
37
|
+
resume_at?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
interface GoalBlockedDetails {
|
|
30
41
|
goal: string;
|
|
31
42
|
goal_id: string;
|
|
@@ -285,8 +296,107 @@ export function registerGoalTools(pi: ExtensionAPI, runtime: GoalRuntime) {
|
|
|
285
296
|
},
|
|
286
297
|
});
|
|
287
298
|
|
|
299
|
+
const goalWaitTool = defineTool({
|
|
300
|
+
name: GOAL_WAIT_TOOL,
|
|
301
|
+
label: "Goal Wait",
|
|
302
|
+
description: `Keep the active /goal alive but quiet while something outside this session is expected — CI finishing, a review landing, a reply arriving. The goal pauses, stays resumable, and records why. Call it alone, after arranging whatever will wake it, or pass resume_after_ms as a safety deadline. Requests below ${MIN_GOAL_WAIT_DELAY_MS}ms are clamped to ${MIN_GOAL_WAIT_DELAY_MS}ms. Do not use it for ordinary unfinished work: if there is anything left you can do yourself, do it instead.`,
|
|
303
|
+
promptSnippet: "Pause the active /goal while waiting on something outside this session",
|
|
304
|
+
promptGuidelines: [
|
|
305
|
+
"Use goal_wait only when progress genuinely depends on a later external event — not when work remains that you could do now.",
|
|
306
|
+
`Prefer deadlines measured in minutes; anything under ${MIN_GOAL_WAIT_DELAY_MS}ms is a polling loop and is clamped.`,
|
|
307
|
+
"Omit resume_after_ms when a message will wake the goal anyway; pass it only as a bounded safety net.",
|
|
308
|
+
"Call goal_wait alone: a sibling tool call in the same turn can prevent the turn from ending.",
|
|
309
|
+
],
|
|
310
|
+
parameters: Type.Object({
|
|
311
|
+
goal_id: Type.String({
|
|
312
|
+
minLength: 1,
|
|
313
|
+
maxLength: MAX_GOAL_ID_LENGTH,
|
|
314
|
+
description: "The exact goal_id shown in the current active /goal prompt.",
|
|
315
|
+
}),
|
|
316
|
+
reason: Type.String({
|
|
317
|
+
minLength: 1,
|
|
318
|
+
maxLength: MAX_GOAL_WAIT_REASON_LENGTH,
|
|
319
|
+
description: "What is being waited for, and what will make it done.",
|
|
320
|
+
}),
|
|
321
|
+
resume_after_ms: Type.Optional(
|
|
322
|
+
Type.Integer({
|
|
323
|
+
minimum: 1,
|
|
324
|
+
description: `Safety wake-up in milliseconds. Clamped up to ${MIN_GOAL_WAIT_DELAY_MS}ms. Omit to wait for external input only.`,
|
|
325
|
+
}),
|
|
326
|
+
),
|
|
327
|
+
}),
|
|
328
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
329
|
+
const goal = runtime.activeGoal;
|
|
330
|
+
const requestedGoalId = params.goal_id.trim();
|
|
331
|
+
const reason = safeTerminalText(params.reason).trim();
|
|
332
|
+
const requestedResumeAfterMs = params.resume_after_ms;
|
|
333
|
+
|
|
334
|
+
const details = (): GoalWaitDetails => {
|
|
335
|
+
const { requestedMs, effectiveMs } = resolveGoalWaitDelay(requestedResumeAfterMs);
|
|
336
|
+
return {
|
|
337
|
+
goal: (goal?.text ?? "").slice(0, MAX_GOAL_TEXT_LENGTH),
|
|
338
|
+
goal_id: requestedGoalId.slice(0, MAX_GOAL_ID_LENGTH),
|
|
339
|
+
reason: reason.slice(0, MAX_GOAL_WAIT_REASON_LENGTH),
|
|
340
|
+
...(requestedMs === undefined ? {} : { requested_resume_after_ms: requestedMs }),
|
|
341
|
+
...(effectiveMs === undefined ? {} : { effective_resume_after_ms: effectiveMs }),
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const reject = (why: string, terminate = false) => {
|
|
346
|
+
const rejection = `goal_wait rejected: ${why}`;
|
|
347
|
+
notifyTerminal(ctx.ui, rejection, "warning");
|
|
348
|
+
return {
|
|
349
|
+
content: toolContent(rejection),
|
|
350
|
+
details: details(),
|
|
351
|
+
...(terminate ? { terminate: true as const } : {}),
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
if (!goal) return reject("no active goal");
|
|
356
|
+
if (!runtime.canRecordGoalUsage()) return reject("current run does not own the active goal");
|
|
357
|
+
if (hasPendingSkipForGoal(runtime, goal.id)) {
|
|
358
|
+
runtime.recordGoalUsage(goal, ctx);
|
|
359
|
+
runtime.persistGoal(goal);
|
|
360
|
+
runtime.updateStatus(ctx, goal);
|
|
361
|
+
runtime.clearBudgetWrapUp();
|
|
362
|
+
return reject("goal is queued to be skipped", true);
|
|
363
|
+
}
|
|
364
|
+
const staleGoalRejection = goalIdRejectionReason(goal, requestedGoalId);
|
|
365
|
+
if (staleGoalRejection) return reject(staleGoalRejection);
|
|
366
|
+
if (goal.status !== "active") return reject(`goal is ${goal.status}, not active`);
|
|
367
|
+
if (!reason) return reject("reason is empty");
|
|
368
|
+
|
|
369
|
+
const wait = createGoalWait(reason, requestedResumeAfterMs);
|
|
370
|
+
const stoppedGoal = runtime.stopActiveGoal(ctx, {
|
|
371
|
+
kind: "wait",
|
|
372
|
+
expectedGoalId: goal.id,
|
|
373
|
+
reason,
|
|
374
|
+
});
|
|
375
|
+
if (!stoppedGoal) return reject("active goal changed before the wait took effect");
|
|
376
|
+
|
|
377
|
+
// Armed after the stop, so a wake can never race a goal that is still
|
|
378
|
+
// being transitioned out of `active`.
|
|
379
|
+
if (wait.resumeAt !== undefined) runtime.scheduleGoalWaitWake(ctx, stoppedGoal.id, wait.resumeAt);
|
|
380
|
+
|
|
381
|
+
const { requestedMs, effectiveMs } = resolveGoalWaitDelay(requestedResumeAfterMs);
|
|
382
|
+
const clamped = requestedMs !== undefined && effectiveMs !== undefined && effectiveMs !== requestedMs;
|
|
383
|
+
const deadline =
|
|
384
|
+
effectiveMs === undefined
|
|
385
|
+
? "It will stay paused until you send it something or resume it."
|
|
386
|
+
: `It will wake in ${Math.round(effectiveMs / 1000)}s${clamped ? ` (raised from ${requestedMs}ms)` : ""}.`;
|
|
387
|
+
notifyTerminal(ctx.ui, `Goal waiting: ${truncateNotification(reason)}`, "info");
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
content: toolContent(`Goal waiting: ${reason}. ${deadline}`),
|
|
391
|
+
details: { ...details(), ...(wait.resumeAt === undefined ? {} : { resume_at: wait.resumeAt }) },
|
|
392
|
+
terminate: true,
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
288
397
|
pi.registerTool(goalCompleteTool);
|
|
289
398
|
pi.registerTool(goalBlockedTool);
|
|
399
|
+
pi.registerTool(goalWaitTool);
|
|
290
400
|
}
|
|
291
401
|
|
|
292
402
|
function toolContent(text: string) {
|
package/src/wait.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wait.ts — `goal_wait`, the difference between "not finished" and "waiting".
|
|
3
|
+
*
|
|
4
|
+
* A goal that depends on something outside the session — CI finishing, a review
|
|
5
|
+
* landing, a colleague replying — has no work left to do right now, but is not
|
|
6
|
+
* complete and is not blocked. Without a way to say so, the model either keeps
|
|
7
|
+
* burning continuation turns re-checking, or reports a blocker that is not one.
|
|
8
|
+
*
|
|
9
|
+
* `goal_wait` says it: the goal stops, stays resumable, records why, and either
|
|
10
|
+
* waits for the next real message or wakes itself on a deadline.
|
|
11
|
+
*
|
|
12
|
+
* The minimum delay is the load-bearing constant. A model asked to "wait a
|
|
13
|
+
* moment" will happily pass 100ms, which is a polling loop wearing a wait's
|
|
14
|
+
* clothing — the same continuation burn the tool exists to prevent. Requests
|
|
15
|
+
* below the floor are clamped rather than refused, since the model's intent
|
|
16
|
+
* ("wait, then check") is right even when its number is not.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface GoalWait {
|
|
20
|
+
reason: string;
|
|
21
|
+
/** Epoch ms of the safety wake-up. Absent = wait for external input only. */
|
|
22
|
+
resumeAt?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MAX_GOAL_WAIT_REASON_LENGTH = 1_000;
|
|
26
|
+
/** Floor on a requested delay. Below this a "wait" is really a poll. */
|
|
27
|
+
export const MIN_GOAL_WAIT_DELAY_MS = 10_000;
|
|
28
|
+
/** `setTimeout` overflows above this and fires immediately, which is the opposite of waiting. */
|
|
29
|
+
export const MAX_GOAL_WAIT_DELAY_MS = 2_147_483_647;
|
|
30
|
+
/** Largest value `Date` represents; anything beyond is not a timestamp. */
|
|
31
|
+
const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Both the requested delay and the one that will actually apply, so a caller
|
|
35
|
+
* can tell the model its number was clamped instead of silently substituting.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveGoalWaitDelay(resumeAfterMs: number | undefined): {
|
|
38
|
+
requestedMs?: number;
|
|
39
|
+
effectiveMs?: number;
|
|
40
|
+
} {
|
|
41
|
+
if (resumeAfterMs === undefined) return {};
|
|
42
|
+
return {
|
|
43
|
+
requestedMs: resumeAfterMs,
|
|
44
|
+
effectiveMs: Math.max(MIN_GOAL_WAIT_DELAY_MS, resumeAfterMs),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build the persisted wait record. `now` is injectable so tests are not timing-dependent. */
|
|
49
|
+
export function createGoalWait(reason: string, resumeAfterMs: number | undefined, now = Date.now()): GoalWait {
|
|
50
|
+
const { effectiveMs } = resolveGoalWaitDelay(resumeAfterMs);
|
|
51
|
+
return {
|
|
52
|
+
reason,
|
|
53
|
+
...(effectiveMs === undefined ? {} : { resumeAt: now + effectiveMs }),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate a wait read back from persisted state.
|
|
59
|
+
*
|
|
60
|
+
* Rejects rather than repairs: a malformed `resumeAt` would otherwise schedule
|
|
61
|
+
* a timer at an arbitrary moment, and a goal that wakes at the wrong time is
|
|
62
|
+
* worse than one that waits for a real message.
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeGoalWait(value: unknown): GoalWait | undefined {
|
|
65
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
66
|
+
const record = value as Record<string, unknown>;
|
|
67
|
+
const reason = typeof record.reason === "string" ? record.reason.trim() : "";
|
|
68
|
+
if (!reason || reason.length > MAX_GOAL_WAIT_REASON_LENGTH) return undefined;
|
|
69
|
+
if (!Object.hasOwn(record, "resumeAt")) return { reason };
|
|
70
|
+
if (
|
|
71
|
+
typeof record.resumeAt !== "number" ||
|
|
72
|
+
!Number.isSafeInteger(record.resumeAt) ||
|
|
73
|
+
record.resumeAt < 0 ||
|
|
74
|
+
record.resumeAt > MAX_DATE_TIMESTAMP_MS
|
|
75
|
+
) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return { reason, resumeAt: record.resumeAt };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The single wake-up timer.
|
|
83
|
+
*
|
|
84
|
+
* Generation-counted rather than merely cleared: a timer already in flight
|
|
85
|
+
* cannot be un-fired, so a callback that was scheduled before a `clear()` must
|
|
86
|
+
* recognize that it is stale and do nothing. Without that, a goal cleared or
|
|
87
|
+
* replaced microseconds before its deadline would still be woken.
|
|
88
|
+
*/
|
|
89
|
+
export class GoalWaitTimer {
|
|
90
|
+
private generation = 0;
|
|
91
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
92
|
+
|
|
93
|
+
clear(): void {
|
|
94
|
+
this.generation += 1;
|
|
95
|
+
if (!this.timer) return;
|
|
96
|
+
clearTimeout(this.timer);
|
|
97
|
+
this.timer = undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
schedule(resumeAt: number, onDue: () => void): void {
|
|
101
|
+
this.clear();
|
|
102
|
+
const generation = this.generation;
|
|
103
|
+
// A deadline already past fires on the next tick rather than never, and one
|
|
104
|
+
// beyond the timer range is capped instead of overflowing to immediate.
|
|
105
|
+
const delay = Math.max(0, Math.min(MAX_GOAL_WAIT_DELAY_MS, resumeAt - Date.now()));
|
|
106
|
+
this.timer = setTimeout(() => {
|
|
107
|
+
if (generation !== this.generation) return;
|
|
108
|
+
this.timer = undefined;
|
|
109
|
+
onDue();
|
|
110
|
+
}, delay);
|
|
111
|
+
}
|
|
112
|
+
}
|