@mjasnikovs/pi-task 0.39.2 → 0.39.4
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/dist/config/register.js +8 -2
- package/dist/config/tool-list.d.ts +10 -0
- package/dist/config/tool-list.js +11 -1
- package/dist/remote/bridge.d.ts +65 -1
- package/dist/remote/bridge.js +142 -22
- package/dist/remote/protocol.d.ts +9 -6
- package/dist/remote/register.js +13 -7
- package/dist/remote/session-state.js +6 -1
- package/dist/remote/ui-script.js +13 -10
- package/dist/remote/ui-styles.d.ts +1 -1
- package/dist/remote/ui-styles.js +3 -4
- package/dist/remote/ui.js +0 -1
- package/dist/task/auto-orchestrator.js +21 -15
- package/dist/task/command-watchdog.d.ts +4 -3
- package/dist/task/command-watchdog.js +7 -4
- package/dist/task/failure-classifier.js +4 -5
- package/dist/task/gate-child.js +3 -2
- package/dist/task/orchestrator.js +18 -15
- package/dist/task/phases.js +2 -2
- package/dist/task/plan-orchestrator.js +14 -14
- package/dist/task/run-final-gate.js +11 -11
- package/dist/task/task-gates.js +22 -16
- package/dist/task/widget.js +30 -39
- package/package.json +1 -1
package/dist/config/register.js
CHANGED
|
@@ -3,7 +3,7 @@ import { effectiveSetting, offeredLevels } from '../shared/reasoning-capability.
|
|
|
3
3
|
import { resolveModel, specOf } from '../shared/model-resolve.js';
|
|
4
4
|
import { MODEL_INHERIT } from './group-models.js';
|
|
5
5
|
import { PairPicker } from './option-picker.js';
|
|
6
|
-
import { registerBridgeCommand } from '../remote/bridge.js';
|
|
6
|
+
import { registerBridgeCommand, isRemoteOrigin, publishNote } from '../remote/bridge.js';
|
|
7
7
|
import { readPkgVersion } from '../shared/pkg-version.js';
|
|
8
8
|
import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
|
|
9
9
|
import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
|
|
@@ -836,7 +836,10 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
|
836
836
|
// it can only be read here, when the menu opens, never at registration.
|
|
837
837
|
const tools = getTools();
|
|
838
838
|
const catalog = liveCatalog(ctx);
|
|
839
|
-
|
|
839
|
+
// The panel is a TUI component. A browser caller has no way to draw it and no
|
|
840
|
+
// way to close it, so awaiting it there parks the remote on the host's
|
|
841
|
+
// terminal — the failure issue #1 fixed for /task-plan.
|
|
842
|
+
if (ctx.mode !== 'tui' || isRemoteOrigin(ctx)) {
|
|
840
843
|
// Built from panelItems and reading the SAME `format` the panel does,
|
|
841
844
|
// so the two renderings cannot disagree about what a setting says. A
|
|
842
845
|
// second walk of the same tables is the one place nobody would notice
|
|
@@ -850,6 +853,9 @@ async function handleTaskConfig(_args, ctx, getTools = () => []) {
|
|
|
850
853
|
`[${i.label.trim()}]`
|
|
851
854
|
: `${(i.headlessLabel ?? i.label).padEnd(22)} ${i.currentValue}`);
|
|
852
855
|
ctx.ui.notify(lines.join(' | '), 'info');
|
|
856
|
+
// One row per line: the `|`-joined form is for a terminal that has one
|
|
857
|
+
// line to spare, and the browser has a whole transcript.
|
|
858
|
+
publishNote(lines.join('\n'));
|
|
853
859
|
return;
|
|
854
860
|
}
|
|
855
861
|
// Built ONCE and shared by the renderer, the dispatch and the refresh, so
|
|
@@ -28,6 +28,16 @@ export interface GuardableTool {
|
|
|
28
28
|
/** Provenance shown in the menu, e.g. "built in" or "npm:pi-fable (/path)". */
|
|
29
29
|
origin: string;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* pi-task's own tools whose child already carries the guard the host watchdog
|
|
33
|
+
* would duplicate — and duplicate WRONGLY. `pi-worker` runs the `adhoc` profile
|
|
34
|
+
* (workers/worker-profiles.ts): no wall clock by decision, bounded on model
|
|
35
|
+
* SILENCE via `stuck reply retry`, plus the loop, churn and dead-backend guards.
|
|
36
|
+
* A host wall clock on top killed healthy workers at the command ceiling while
|
|
37
|
+
* they were still reading. Never armed, never offered as a `watch:` row: the
|
|
38
|
+
* operator should not be handed a switch that is only ever correct one way.
|
|
39
|
+
*/
|
|
40
|
+
export declare const SELF_BOUNDED_TOOLS: ReadonlySet<string>;
|
|
31
41
|
/**
|
|
32
42
|
* Every tool in the live session, built-ins first and each extension's tools in
|
|
33
43
|
* registration order after them, so the menu reads owner-by-owner.
|
package/dist/config/tool-list.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
/** The `source` pi reports for its own tools rather than an extension's. */
|
|
2
2
|
const BUILTIN_SOURCE = 'builtin';
|
|
3
|
+
/**
|
|
4
|
+
* pi-task's own tools whose child already carries the guard the host watchdog
|
|
5
|
+
* would duplicate — and duplicate WRONGLY. `pi-worker` runs the `adhoc` profile
|
|
6
|
+
* (workers/worker-profiles.ts): no wall clock by decision, bounded on model
|
|
7
|
+
* SILENCE via `stuck reply retry`, plus the loop, churn and dead-backend guards.
|
|
8
|
+
* A host wall clock on top killed healthy workers at the command ceiling while
|
|
9
|
+
* they were still reading. Never armed, never offered as a `watch:` row: the
|
|
10
|
+
* operator should not be handed a switch that is only ever correct one way.
|
|
11
|
+
*/
|
|
12
|
+
export const SELF_BOUNDED_TOOLS = new Set(['pi-worker']);
|
|
3
13
|
/**
|
|
4
14
|
* Human provenance for a tool's source metadata. `source` is pi's own word for
|
|
5
15
|
* where the owner came from ("builtin", "npm:...", "auto", "cli"); the two
|
|
@@ -30,7 +40,7 @@ export function toGuardableTools(tools) {
|
|
|
30
40
|
// A duplicate name cannot be told apart by the watchdog (it only ever
|
|
31
41
|
// sees `toolName`), so a second registration must not add a second row
|
|
32
42
|
// that silently toggles the first one's guard.
|
|
33
|
-
if (seen.has(t.name))
|
|
43
|
+
if (seen.has(t.name) || SELF_BOUNDED_TOOLS.has(t.name))
|
|
34
44
|
continue;
|
|
35
45
|
seen.add(t.name);
|
|
36
46
|
const entry = { name: t.name, origin: toolOrigin(t.sourceInfo.source, t.sourceInfo.path) };
|
package/dist/remote/bridge.d.ts
CHANGED
|
@@ -17,6 +17,17 @@ export declare function getBridge(): BridgeState;
|
|
|
17
17
|
/** Resolve the remote side of a pending prompt. First call wins; later calls
|
|
18
18
|
* (duplicate frames, a second browser, local-after-remote) are ignored. */
|
|
19
19
|
export declare function answerPrompt(id: string, value: string | undefined): void;
|
|
20
|
+
/**
|
|
21
|
+
* Settle every parked ask with undefined. Its one caller is the remote `/new`,
|
|
22
|
+
* whose reset wipes the prompt slot and tells the browser to close its card,
|
|
23
|
+
* leaving the resolver here with no surface on any device — and, on a host with
|
|
24
|
+
* no TUI, no local half either.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately NOT on every `session_start`: that also fires for the /task
|
|
27
|
+
* handoff's own newSession, where cancelling would read to the planner as the
|
|
28
|
+
* user skipping a question they were never shown.
|
|
29
|
+
*/
|
|
30
|
+
export declare function cancelPendingPrompts(): void;
|
|
20
31
|
export interface AskSpec {
|
|
21
32
|
/** Themed, possibly multi-line title for the local TUI input. */
|
|
22
33
|
localTitle: string;
|
|
@@ -86,6 +97,17 @@ export interface AskSpec {
|
|
|
86
97
|
value: string;
|
|
87
98
|
}[];
|
|
88
99
|
}
|
|
100
|
+
/** A read-only display fanned out to both surfaces by {@link SessionUI.show}. */
|
|
101
|
+
export interface ShowSpec {
|
|
102
|
+
/** Title for the local terminal editor. */
|
|
103
|
+
localTitle: string;
|
|
104
|
+
/** Full text the local editor opens with. */
|
|
105
|
+
localText: string;
|
|
106
|
+
/** Card header on the browser. Plain text. */
|
|
107
|
+
question: string;
|
|
108
|
+
/** Card body on the browser, markdown-rendered in the panel. */
|
|
109
|
+
body: string;
|
|
110
|
+
}
|
|
89
111
|
/** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
|
|
90
112
|
export declare class SessionUI {
|
|
91
113
|
private readonly ctx;
|
|
@@ -95,6 +117,22 @@ export declare class SessionUI {
|
|
|
95
117
|
get hasUI(): boolean;
|
|
96
118
|
/** Race the local input against a remote answer; first to settle wins. */
|
|
97
119
|
ask(spec: AskSpec): Promise<string | undefined>;
|
|
120
|
+
/**
|
|
121
|
+
* Show text that only needs dismissing. Locally that is the terminal editor;
|
|
122
|
+
* remotely it is a dismiss-only prompt card, and it has to be a prompt rather
|
|
123
|
+
* than a `viewer` because only the prompt slot is in the reconnect snapshot
|
|
124
|
+
* and only a prompt can settle the caller from the browser.
|
|
125
|
+
*
|
|
126
|
+
* pi's `ui.editor` takes no AbortSignal, so a remote dismissal cannot close
|
|
127
|
+
* the terminal editor the way a remote answer aborts a local ask. It is left
|
|
128
|
+
* open and whatever it eventually returns is discarded — the run has moved on.
|
|
129
|
+
*/
|
|
130
|
+
show(spec: ShowSpec): Promise<void>;
|
|
131
|
+
/**
|
|
132
|
+
* The shared half of ask() and show(): publish one prompt card, open the
|
|
133
|
+
* local dialog beside it, and take whichever settles first.
|
|
134
|
+
*/
|
|
135
|
+
private race;
|
|
98
136
|
/**
|
|
99
137
|
* The local-TUI half of ask(). With `spec.options` it renders the boxed
|
|
100
138
|
* picker (each answer in its own bounding box, the first/recommended one
|
|
@@ -126,7 +164,33 @@ export declare function interruptAgent(): void;
|
|
|
126
164
|
* transient toast since they don't need to linger.
|
|
127
165
|
*/
|
|
128
166
|
export declare function publishLifecycleNotice(message: string, level: 'info' | 'warning' | 'error'): void;
|
|
129
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Say it on BOTH surfaces. The local notify is attempted first and its failure
|
|
169
|
+
* absorbed — a stale ctx must not cost the remote the message, which is the one
|
|
170
|
+
* surface still there when nobody is at the terminal.
|
|
171
|
+
*/
|
|
172
|
+
export declare function notifyBoth(ctx: Pick<ExtensionCommandContext, 'ui'>, message: string, level: 'info' | 'warning' | 'error'): void;
|
|
173
|
+
/** {@link notifyBoth} for something that must outlive a toast — see
|
|
174
|
+
* {@link publishRunNotice}. */
|
|
175
|
+
export declare function notifyRun(ctx: Pick<ExtensionCommandContext, 'ui'>, message: string, level: 'info' | 'warning' | 'error'): void;
|
|
176
|
+
/**
|
|
177
|
+
* A run outcome the remote must still have later: what a gate decided, what a
|
|
178
|
+
* commit failed to do, what a resume refused. Unlike publishLifecycleNotice it
|
|
179
|
+
* persists at EVERY level, because "still there in the morning" is the reason
|
|
180
|
+
* these are published at all — a warning that vanishes after four seconds tells
|
|
181
|
+
* an absent user nothing.
|
|
182
|
+
*/
|
|
183
|
+
export declare function publishRunNotice(message: string, level: 'info' | 'warning' | 'error'): void;
|
|
184
|
+
/** Commit text to the remote transcript. Unlike a notify it is in the reconnect
|
|
185
|
+
* snapshot, so it is still there after a refresh. */
|
|
186
|
+
export declare function publishNote(text: string): void;
|
|
187
|
+
/**
|
|
188
|
+
* True when this ctx reached the handler through the browser rather than the
|
|
189
|
+
* terminal. A command that can only draw itself on the host TUI must check it:
|
|
190
|
+
* awaiting a local overlay for a remote caller parks the browser on a dialog
|
|
191
|
+
* nobody is sitting in front of.
|
|
192
|
+
*/
|
|
193
|
+
export declare function isRemoteOrigin(ctx: ExtensionCommandContext): boolean;
|
|
130
194
|
/**
|
|
131
195
|
* Wraps an event-scoped ExtensionContext so it can be used as a command ctx
|
|
132
196
|
* for commands that don't need newSession (e.g. /task-resume, /task-list,
|
package/dist/remote/bridge.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { broadcast as wsBroadcast } from './broadcast.js';
|
|
2
2
|
import { pushNotify } from './push.js';
|
|
3
|
-
import { setPrompt, clearPrompt, addError } from './session-state.js';
|
|
3
|
+
import { setPrompt, clearPrompt, addError, addSystemNote, getState } from './session-state.js';
|
|
4
4
|
import { askQuestionBox } from '../task/question-box.js';
|
|
5
5
|
const g = globalThis;
|
|
6
6
|
export function getBridge() {
|
|
@@ -26,6 +26,22 @@ export function answerPrompt(id, value) {
|
|
|
26
26
|
b.pending.delete(id);
|
|
27
27
|
settle(value);
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Settle every parked ask with undefined. Its one caller is the remote `/new`,
|
|
31
|
+
* whose reset wipes the prompt slot and tells the browser to close its card,
|
|
32
|
+
* leaving the resolver here with no surface on any device — and, on a host with
|
|
33
|
+
* no TUI, no local half either.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately NOT on every `session_start`: that also fires for the /task
|
|
36
|
+
* handoff's own newSession, where cancelling would read to the planner as the
|
|
37
|
+
* user skipping a question they were never shown.
|
|
38
|
+
*/
|
|
39
|
+
export function cancelPendingPrompts() {
|
|
40
|
+
const b = getBridge();
|
|
41
|
+
for (const settle of b.pending.values())
|
|
42
|
+
settle(undefined);
|
|
43
|
+
b.pending.clear();
|
|
44
|
+
}
|
|
29
45
|
/** Wraps a live command ctx and fans interactions out to local TUI + browsers. */
|
|
30
46
|
export class SessionUI {
|
|
31
47
|
ctx;
|
|
@@ -42,35 +58,78 @@ export class SessionUI {
|
|
|
42
58
|
}
|
|
43
59
|
/** Race the local input against a remote answer; first to settle wins. */
|
|
44
60
|
async ask(spec) {
|
|
45
|
-
|
|
46
|
-
const id = String(b.nextId++);
|
|
47
|
-
const ac = new AbortController();
|
|
48
|
-
const remote = new Promise(resolve => {
|
|
49
|
-
b.pending.set(id, resolve);
|
|
50
|
-
});
|
|
51
|
-
const prompt = {
|
|
61
|
+
return this.race(id => ({
|
|
52
62
|
type: 'prompt',
|
|
53
63
|
id,
|
|
54
64
|
question: spec.question,
|
|
55
65
|
recommended: spec.recommended,
|
|
56
66
|
...(spec.recommended2 !== undefined && { recommended2: spec.recommended2 }),
|
|
57
|
-
...(spec.actions !== undefined
|
|
67
|
+
...(spec.actions !== undefined
|
|
68
|
+
&& spec.actions.length > 0 && { actions: spec.actions }),
|
|
58
69
|
allowSkip: spec.allowSkip
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
}), spec.question, signal => this.askLocal(spec, signal));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Show text that only needs dismissing. Locally that is the terminal editor;
|
|
74
|
+
* remotely it is a dismiss-only prompt card, and it has to be a prompt rather
|
|
75
|
+
* than a `viewer` because only the prompt slot is in the reconnect snapshot
|
|
76
|
+
* and only a prompt can settle the caller from the browser.
|
|
77
|
+
*
|
|
78
|
+
* pi's `ui.editor` takes no AbortSignal, so a remote dismissal cannot close
|
|
79
|
+
* the terminal editor the way a remote answer aborts a local ask. It is left
|
|
80
|
+
* open and whatever it eventually returns is discarded — the run has moved on.
|
|
81
|
+
*/
|
|
82
|
+
async show(spec) {
|
|
83
|
+
// Nothing to wait for: with no local editor the only surface is the
|
|
84
|
+
// browser, and a DISPLAY that blocks a run until someone opens one is
|
|
85
|
+
// worse than a note nobody dismisses.
|
|
86
|
+
if (!this.ctx.hasUI) {
|
|
87
|
+
publishNote(`${spec.question}\n${spec.body}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
await this.race(id => ({
|
|
91
|
+
type: 'prompt',
|
|
92
|
+
id,
|
|
93
|
+
question: spec.question,
|
|
94
|
+
recommended: spec.body,
|
|
95
|
+
dismissOnly: true,
|
|
96
|
+
allowSkip: false
|
|
97
|
+
}),
|
|
98
|
+
// No push: a read-only card is not a request for input, and waking a
|
|
99
|
+
// phone to say it is would be a lie.
|
|
100
|
+
null, () => this.ctx.ui.editor(spec.localTitle, spec.localText));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The shared half of ask() and show(): publish one prompt card, open the
|
|
104
|
+
* local dialog beside it, and take whichever settles first.
|
|
105
|
+
*/
|
|
106
|
+
async race(build, pushBody, local) {
|
|
107
|
+
const b = this.bridge;
|
|
108
|
+
const id = String(b.nextId++);
|
|
109
|
+
const ac = new AbortController();
|
|
110
|
+
const remote = new Promise(resolve => {
|
|
111
|
+
b.pending.set(id, resolve);
|
|
112
|
+
});
|
|
113
|
+
// The slot holds one prompt. Whatever is in it is put BACK when this one
|
|
114
|
+
// settles, or a /task-list would leave a parked question unanswerable
|
|
115
|
+
// from every browser — off the card and out of the snapshot both.
|
|
116
|
+
const displaced = getState().prompt;
|
|
117
|
+
setPrompt(build(id));
|
|
118
|
+
if (pushBody !== null) {
|
|
119
|
+
// Reaches a backgrounded/suspended phone, which the in-page UI can't. The
|
|
120
|
+
// service worker drops the banner if a window is visible+focused (sw.ts),
|
|
121
|
+
// so we always push and let delivery-time visibility decide.
|
|
122
|
+
void pushNotify('pi needs your input', pushBody, 'pi-prompt').catch(() => { });
|
|
123
|
+
}
|
|
65
124
|
// Local: resolves to a value, or to undefined on cancel or abort. The
|
|
66
125
|
// catch is belt-and-braces — a rejection here would surface as an
|
|
67
126
|
// unhandled one rather than as a lost race.
|
|
68
|
-
const
|
|
69
|
-
|
|
127
|
+
const localSide = this.ctx.hasUI ?
|
|
128
|
+
local(ac.signal).catch(() => undefined)
|
|
70
129
|
: new Promise(() => { });
|
|
71
130
|
try {
|
|
72
131
|
const winner = await Promise.race([
|
|
73
|
-
|
|
132
|
+
localSide.then(v => ({ from: 'local', v })),
|
|
74
133
|
remote.then(v => ({ from: 'remote', v }))
|
|
75
134
|
]);
|
|
76
135
|
if (winner.from === 'remote')
|
|
@@ -80,6 +139,8 @@ export class SessionUI {
|
|
|
80
139
|
finally {
|
|
81
140
|
b.pending.delete(id);
|
|
82
141
|
clearPrompt(id);
|
|
142
|
+
if (displaced && b.pending.has(displaced.id))
|
|
143
|
+
setPrompt(displaced);
|
|
83
144
|
}
|
|
84
145
|
}
|
|
85
146
|
/**
|
|
@@ -141,11 +202,61 @@ export function publishLifecycleNotice(message, level) {
|
|
|
141
202
|
else
|
|
142
203
|
publishNotify(message, level);
|
|
143
204
|
}
|
|
144
|
-
|
|
145
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Say it on BOTH surfaces. The local notify is attempted first and its failure
|
|
207
|
+
* absorbed — a stale ctx must not cost the remote the message, which is the one
|
|
208
|
+
* surface still there when nobody is at the terminal.
|
|
209
|
+
*/
|
|
210
|
+
export function notifyBoth(ctx, message, level) {
|
|
211
|
+
try {
|
|
212
|
+
ctx.ui.notify(message, level);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
/* stale ctx — the remote half below is what matters */
|
|
216
|
+
}
|
|
217
|
+
publishLifecycleNotice(message, level);
|
|
218
|
+
}
|
|
219
|
+
/** {@link notifyBoth} for something that must outlive a toast — see
|
|
220
|
+
* {@link publishRunNotice}. */
|
|
221
|
+
export function notifyRun(ctx, message, level) {
|
|
222
|
+
try {
|
|
223
|
+
ctx.ui.notify(message, level);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* stale ctx — the remote half below is what matters */
|
|
227
|
+
}
|
|
228
|
+
publishRunNotice(message, level);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A run outcome the remote must still have later: what a gate decided, what a
|
|
232
|
+
* commit failed to do, what a resume refused. Unlike publishLifecycleNotice it
|
|
233
|
+
* persists at EVERY level, because "still there in the morning" is the reason
|
|
234
|
+
* these are published at all — a warning that vanishes after four seconds tells
|
|
235
|
+
* an absent user nothing.
|
|
236
|
+
*/
|
|
237
|
+
export function publishRunNotice(message, level) {
|
|
238
|
+
if (level === 'error')
|
|
239
|
+
addError(message);
|
|
240
|
+
else
|
|
241
|
+
addSystemNote(message);
|
|
242
|
+
}
|
|
243
|
+
/** Commit text to the remote transcript. Unlike a notify it is in the reconnect
|
|
244
|
+
* snapshot, so it is still there after a refresh. */
|
|
245
|
+
export function publishNote(text) {
|
|
246
|
+
addSystemNote(text);
|
|
146
247
|
}
|
|
147
248
|
// ─── Shimmed ctx ─────────────────────────────────────────────────────────────
|
|
148
249
|
const SHIMMED_MARKER = '__piRemoteShimmed';
|
|
250
|
+
const REMOTE_ORIGIN_MARKER = '__piRemoteOrigin';
|
|
251
|
+
/**
|
|
252
|
+
* True when this ctx reached the handler through the browser rather than the
|
|
253
|
+
* terminal. A command that can only draw itself on the host TUI must check it:
|
|
254
|
+
* awaiting a local overlay for a remote caller parks the browser on a dialog
|
|
255
|
+
* nobody is sitting in front of.
|
|
256
|
+
*/
|
|
257
|
+
export function isRemoteOrigin(ctx) {
|
|
258
|
+
return ctx[REMOTE_ORIGIN_MARKER] === true;
|
|
259
|
+
}
|
|
149
260
|
/**
|
|
150
261
|
* Wraps an event-scoped ExtensionContext so it can be used as a command ctx
|
|
151
262
|
* for commands that don't need newSession (e.g. /task-resume, /task-list,
|
|
@@ -178,7 +289,12 @@ export function registerBridgeCommand(pi, name, def) {
|
|
|
178
289
|
const wrapped = {
|
|
179
290
|
...def,
|
|
180
291
|
handler: (args, ctx) => {
|
|
181
|
-
|
|
292
|
+
// A remote-dispatched ctx is a clone of this very field. Storing it
|
|
293
|
+
// back would make the next remote line clone the clone — one more
|
|
294
|
+
// prototype link per command, for the life of the session — and would
|
|
295
|
+
// leave the marker on the ctx a later TERMINAL command is handed.
|
|
296
|
+
if (!isRemoteOrigin(ctx))
|
|
297
|
+
b.currentCtx = ctx;
|
|
182
298
|
return def.handler(args, ctx);
|
|
183
299
|
}
|
|
184
300
|
};
|
|
@@ -242,8 +358,12 @@ export function dispatchRemoteLine(text, opts) {
|
|
|
242
358
|
// sync throws and async rejections from the (often async) command handler
|
|
243
359
|
// as a toast instead of crashing or becoming an unhandled rejection.
|
|
244
360
|
const toastErr = (err) => publishNotify(`/${name} failed: ${err.message}`, 'error');
|
|
361
|
+
// A prototype clone rather than the ctx itself: the marker must not leak onto
|
|
362
|
+
// the shared currentCtx and make the NEXT terminal invocation look remote.
|
|
363
|
+
const remoteCtx = Object.create(b.currentCtx);
|
|
364
|
+
remoteCtx[REMOTE_ORIGIN_MARKER] = true;
|
|
245
365
|
try {
|
|
246
|
-
const result = handler(args,
|
|
366
|
+
const result = handler(args, remoteCtx);
|
|
247
367
|
if (result instanceof Promise)
|
|
248
368
|
result.catch(toastErr);
|
|
249
369
|
}
|
|
@@ -14,6 +14,14 @@ export interface PromptMessage {
|
|
|
14
14
|
label: string;
|
|
15
15
|
value: string;
|
|
16
16
|
}[];
|
|
17
|
+
/**
|
|
18
|
+
* The card only DISPLAYS. `recommended` carries the body, and the buttons
|
|
19
|
+
* collapse to a single Close that answers with an empty string. Its one
|
|
20
|
+
* producer is /task-plan's answer to a question the USER asked, which needs
|
|
21
|
+
* dismissing rather than answering — and which needs to be a prompt, not a
|
|
22
|
+
* broadcast-only frame, because only a prompt is in the reconnect snapshot.
|
|
23
|
+
*/
|
|
24
|
+
dismissOnly?: boolean;
|
|
17
25
|
allowSkip: boolean;
|
|
18
26
|
}
|
|
19
27
|
export interface PromptResolvedMessage {
|
|
@@ -58,11 +66,6 @@ export interface HeldMessage {
|
|
|
58
66
|
/** True while a task run owns the session (drives the composer placeholder). */
|
|
59
67
|
runActive: boolean;
|
|
60
68
|
}
|
|
61
|
-
export interface ViewerMessage {
|
|
62
|
-
type: 'viewer';
|
|
63
|
-
title: string;
|
|
64
|
-
text: string;
|
|
65
|
-
}
|
|
66
69
|
export interface ContextUsage {
|
|
67
70
|
tokens?: number;
|
|
68
71
|
contextWindow?: number;
|
|
@@ -87,7 +90,7 @@ export type { SnapshotMessage } from './session-state.js';
|
|
|
87
90
|
/** Server → browser messages. The live text_delta / tool_* / agent_* /
|
|
88
91
|
* user_message deltas are emitted by the SessionState mutators
|
|
89
92
|
* and not all enumerated here; the snapshot below carries the full state. */
|
|
90
|
-
export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage |
|
|
93
|
+
export type ServerMessage = PromptMessage | PromptResolvedMessage | WidgetMessage | NotifyMessage | HeldMessage | ContextMessage | ResetMessage | import('./session-state.js').SnapshotMessage;
|
|
91
94
|
/** Browser → server messages. */
|
|
92
95
|
export interface ClientChatMessage {
|
|
93
96
|
type: 'message';
|
package/dist/remote/register.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getConfig } from '../config/config.js';
|
|
2
|
-
import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent, registerBridgeCommand, registerRemoteOnlyCommand, publishNotify } from './bridge.js';
|
|
2
|
+
import { getBridge, dispatchRemoteLine, dispatchRemoteNewSession, makeShimmedCtx, interruptAgent, registerBridgeCommand, registerRemoteOnlyCommand, publishNotify, cancelPendingPrompts, notifyBoth } from './bridge.js';
|
|
3
3
|
import { setupEvents } from './events.js';
|
|
4
4
|
import { reset, addUserTurn, setHeld, getState } from './session-state.js';
|
|
5
5
|
import { html } from './ui.js';
|
|
@@ -45,6 +45,12 @@ export function registerRemote(pi) {
|
|
|
45
45
|
return S.server;
|
|
46
46
|
S.server = await startServer(text => {
|
|
47
47
|
if (text === '/new') {
|
|
48
|
+
// The reset inside a new session clears the browser's prompt
|
|
49
|
+
// card, so a parked ask has lost its last surface. Only HERE:
|
|
50
|
+
// session_start also fires for the /task handoff's own
|
|
51
|
+
// newSession, and cancelling there reads to the planner as the
|
|
52
|
+
// user skipping a question they never saw.
|
|
53
|
+
cancelPendingPrompts();
|
|
48
54
|
dispatchRemoteNewSession(newCtx => {
|
|
49
55
|
S.send = (msg, opts) => {
|
|
50
56
|
void (opts ?
|
|
@@ -89,7 +95,7 @@ export function registerRemote(pi) {
|
|
|
89
95
|
if (getConfig().remote) {
|
|
90
96
|
// Optional feature: a bind failure must never take pi down. Degrade
|
|
91
97
|
// to a one-line warning and keep the agent running without remote.
|
|
92
|
-
void ensureServer().catch(err => ctx
|
|
98
|
+
void ensureServer().catch(err => notifyBoth(ctx, `Remote UI unavailable: ${err.message}`, 'warning'));
|
|
93
99
|
}
|
|
94
100
|
});
|
|
95
101
|
pi.on('session_shutdown', (event, _ctx) => {
|
|
@@ -124,7 +130,7 @@ export function registerRemote(pi) {
|
|
|
124
130
|
description: 'Show the remote QR code & URLs.',
|
|
125
131
|
handler: async (args, ctx) => {
|
|
126
132
|
if (!getConfig().remote) {
|
|
127
|
-
ctx
|
|
133
|
+
notifyBoth(ctx, 'Remote is disabled — enable it in /task-config.', 'info');
|
|
128
134
|
return;
|
|
129
135
|
}
|
|
130
136
|
if (args.trim() === 'stop') {
|
|
@@ -134,10 +140,10 @@ export function registerRemote(pi) {
|
|
|
134
140
|
S.server = null;
|
|
135
141
|
S.serveResult = null;
|
|
136
142
|
void teardownTailscaleServe(port).catch(() => { });
|
|
137
|
-
ctx
|
|
143
|
+
notifyBoth(ctx, 'Remote server stopped', 'info');
|
|
138
144
|
}
|
|
139
145
|
else {
|
|
140
|
-
ctx
|
|
146
|
+
notifyBoth(ctx, 'Remote server is not running', 'warning');
|
|
141
147
|
}
|
|
142
148
|
return;
|
|
143
149
|
}
|
|
@@ -193,10 +199,10 @@ export function registerRemote(pi) {
|
|
|
193
199
|
})
|
|
194
200
|
.catch(() => { });
|
|
195
201
|
}
|
|
196
|
-
ctx
|
|
202
|
+
notifyBoth(ctx, `Remote running at ${primaryUrl}`, 'info');
|
|
197
203
|
}
|
|
198
204
|
catch (err) {
|
|
199
|
-
ctx
|
|
205
|
+
notifyBoth(ctx, `Remote UI unavailable: ${err.message}`, 'error');
|
|
200
206
|
}
|
|
201
207
|
}
|
|
202
208
|
});
|
|
@@ -198,7 +198,12 @@ export function setPrompt(prompt) {
|
|
|
198
198
|
}
|
|
199
199
|
export function clearPrompt(id) {
|
|
200
200
|
const s = getState();
|
|
201
|
-
|
|
201
|
+
// Only the prompt that is actually on screen. Two asks can be live at once —
|
|
202
|
+
// dispatchRemoteLine starts a command whatever else is running — and the slot
|
|
203
|
+
// holds one, so clearing unconditionally would drop the survivor out of the
|
|
204
|
+
// snapshot and leave a reconnecting browser unable to answer it.
|
|
205
|
+
if (s.prompt?.id === id)
|
|
206
|
+
s.prompt = null;
|
|
202
207
|
s.sink({ type: 'prompt_resolved', id });
|
|
203
208
|
}
|
|
204
209
|
export function setContext(context) {
|
package/dist/remote/ui-script.js
CHANGED
|
@@ -101,9 +101,6 @@ export function clientScript(wsUrl) {
|
|
|
101
101
|
const promptRecText = document.getElementById('prompt-rec-text');
|
|
102
102
|
const promptInput = document.getElementById('prompt-input');
|
|
103
103
|
const promptButtons = document.getElementById('prompt-buttons');
|
|
104
|
-
const viewer = document.getElementById('viewer');
|
|
105
|
-
const viewerBody = document.getElementById('viewer-body');
|
|
106
|
-
document.getElementById('viewer-close').onclick = function () { viewer.style.display = 'none'; };
|
|
107
104
|
let activePromptId = null;
|
|
108
105
|
let activeRecommended = '';
|
|
109
106
|
let activeRecommended2 = '';
|
|
@@ -832,13 +829,16 @@ export function clientScript(wsUrl) {
|
|
|
832
829
|
return out;
|
|
833
830
|
}
|
|
834
831
|
|
|
835
|
-
|
|
832
|
+
// omitCancel is for the dismiss-only card: there is nothing to cancel, and a
|
|
833
|
+
// Cancel task button beside a read-only answer would offer to abort the run
|
|
834
|
+
// when the only real choice is to close.
|
|
835
|
+
function renderButtons(buttons, stacked, omitCancel) {
|
|
836
836
|
promptButtons.className = stacked ? 'row stacked' : 'row';
|
|
837
837
|
promptButtons.innerHTML = '';
|
|
838
838
|
for (let i = 0; i < buttons.length; i++) promptButtons.appendChild(buttons[i]);
|
|
839
839
|
const actions = makeActionBtns();
|
|
840
840
|
for (let i = 0; i < actions.length; i++) promptButtons.appendChild(actions[i]);
|
|
841
|
-
promptButtons.appendChild(makeCancelBtn());
|
|
841
|
+
if (!omitCancel) promptButtons.appendChild(makeCancelBtn());
|
|
842
842
|
}
|
|
843
843
|
|
|
844
844
|
// Manual-entry view: empty textarea + Submit, reachable from the
|
|
@@ -881,7 +881,14 @@ export function clientScript(wsUrl) {
|
|
|
881
881
|
activeRecommended = msg.recommended || '';
|
|
882
882
|
activeRecommended2 = msg.recommended2 || '';
|
|
883
883
|
activeActions = msg.actions || [];
|
|
884
|
-
if (msg.
|
|
884
|
+
if (msg.dismissOnly) {
|
|
885
|
+
// Mode C: nothing to answer. The body goes in the panel so it renders as
|
|
886
|
+
// markdown, and Close is the only way out.
|
|
887
|
+
promptInput.style.display = 'none';
|
|
888
|
+
promptRec.style.display = 'block';
|
|
889
|
+
setContent(promptRecText, msg.recommended || '');
|
|
890
|
+
renderButtons([makeBtn('Close', 'primary', function () { answer(''); })], false, true);
|
|
891
|
+
} else if (msg.recommended) {
|
|
885
892
|
// Mode A: recommendation(s) present. Render markdown in the panel so a
|
|
886
893
|
// recommendation with code/emphasis reads the same as an assistant bubble.
|
|
887
894
|
setContent(promptRecText, msg.recommended);
|
|
@@ -1086,10 +1093,6 @@ export function clientScript(wsUrl) {
|
|
|
1086
1093
|
case 'notify':
|
|
1087
1094
|
showToast(msg.message, msg.level);
|
|
1088
1095
|
break;
|
|
1089
|
-
case 'viewer':
|
|
1090
|
-
viewerBody.textContent = msg.text;
|
|
1091
|
-
viewer.style.display = 'block';
|
|
1092
|
-
break;
|
|
1093
1096
|
case 'reset':
|
|
1094
1097
|
// A new session started — wipe the previous session's transcript.
|
|
1095
1098
|
chatLog.innerHTML = '';
|