oira666_pi-watcher 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +132 -2
- package/package.json +54 -4
- package/src/config.ts +56 -0
- package/src/index.ts +293 -0
- package/src/live-tools.ts +66 -0
- package/src/runtime-args.ts +343 -0
- package/src/runtime-bridge-extension.ts +249 -0
- package/src/runtime-child.mjs +88 -0
- package/src/runtime-filter.d.mts +38 -0
- package/src/runtime-filter.mjs +198 -0
- package/src/runtime-prompt.ts +48 -0
- package/src/runtime-protocol.ts +85 -0
- package/src/runtime-rpc.ts +245 -0
- package/src/runtime.ts +1339 -0
- package/src/ui-render.ts +121 -0
- package/src/ui-store.ts +249 -0
- package/src/ui-types.ts +131 -0
- package/src/ui.ts +776 -0
- package/watcher-settings.example.json +4 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gee666
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,133 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pi Watcher
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A persistent side-agent conversation for your running [Pi](https://pi.dev) agent.
|
|
4
|
+
|
|
5
|
+
**Package:** `oira666_pi-watcher` · **Version:** `0.0.2` · **License:** MIT
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pi install npm:oira666_pi-watcher
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then restart Pi or run `/reload`. Requires Node.js **22.19+** and the modular Pi installation. Tested against **Pi 1.0.4**; the child launcher depends on that version's package-manager loading interfaces. Standalone compiled Pi binaries are not supported.
|
|
14
|
+
|
|
15
|
+
For local development:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm ci
|
|
19
|
+
pi -e ./src/index.ts
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
| Command | Action |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `/watcher` | Create/resume the side agent and show its conversation. **Sends no prompt.** |
|
|
27
|
+
| `/watcher Why is it retrying?` | Open and send exactly that question. |
|
|
28
|
+
| `/watcher model` | Open the side agent's model picker. Does not prompt either agent. |
|
|
29
|
+
|
|
30
|
+
These are human-facing TUI commands, not tools exposed to the main agent. They work while the main agent is running, including during compaction.
|
|
31
|
+
|
|
32
|
+
The panel opens immediately while the side process initializes its inherited extensions and MCPs in the background. You can type straight away; a submitted question waits for initialization and is sent once ready. `/stop` cancels a question waiting for startup. Opening without a question still sends no prompt. Saved history and the selected model appear when initialization finishes; reopening an already initialized side agent reuses it.
|
|
33
|
+
|
|
34
|
+
Inside the overlay:
|
|
35
|
+
|
|
36
|
+
- Type a question and press **Enter**. Use **Shift+Enter** for a newline.
|
|
37
|
+
- **Esc** returns to the main view without stopping either agent. Run `/watcher` again to return; conversation, draft, and scroll position are retained.
|
|
38
|
+
- `/model` opens a searchable provider/model picker. Its selection affects **only the side agent** and is persisted in that side conversation. You can change models while the side agent is running, just like in Pi; switching does not abort the current reply.
|
|
39
|
+
- `/stop` cancels only the side agent and clears its queued messages.
|
|
40
|
+
- `/help` shows controls. **Page Up/Down**, **Ctrl+Home/End**, or the mouse wheel scroll the conversation.
|
|
41
|
+
- Watcher never modifies the main status bar. Reopen the overlay to see pending approvals; finished replies can trigger a notification.
|
|
42
|
+
|
|
43
|
+
A question passed to `/watcher` while the side agent is already replying is saved as a draft rather than silently queued. The overlay displays the latest 120 messages; older history remains in the side session file and model conversation.
|
|
44
|
+
|
|
45
|
+
### Steering the main agent
|
|
46
|
+
|
|
47
|
+
Ask the side agent to send an instruction. Its `steer_main` tool presents **Send / Edit / Cancel** in the overlay. Nothing is sent without your explicit approval. You can close the overlay and reopen it to answer later.
|
|
48
|
+
|
|
49
|
+
Approved messages enter Pi's normal steering/follow-up queue, with attribution:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
[Watcher — approved by you] Inspect the database setup failure before retrying.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
They appear like other user messages. Steering takes effect at Pi's next safe turn boundary, not in the middle of an executing tool. Sending while main is idle starts a new turn (the approval warns about this). During main-session compaction, delivery is refused with a visible notice rather than falsely reported as sent.
|
|
56
|
+
|
|
57
|
+
## What the side agent sees
|
|
58
|
+
|
|
59
|
+
Its system prompt names the **main session JSONL path**, session ID, and the two added tools:
|
|
60
|
+
|
|
61
|
+
- **`live_main_tools`**: current running tools, elapsed time, short input/output previews, and the main branch leaf ID.
|
|
62
|
+
- **`steer_main`**: human-approved messages to the main agent.
|
|
63
|
+
|
|
64
|
+
It uses the inherited file reader for session history. The prompt explicitly distinguishes the authoritative main transcript from Watcher's own session file: `PI_SESSION_FILE` and `PI_SESSION_ID` in Watcher's shell refer to Watcher, not the main agent. It reads the supplied main path directly rather than searching session directories. The prompt tells it to read **only session files and live tool data** by default—not source code, tests, git, or other checks. It must not redo or verify the main agent's work unless you specifically ask. The same rule applies even though inherited tools remain available.
|
|
65
|
+
|
|
66
|
+
The live tracker keeps at most **64 in-flight calls**, each with tiny previews. It stores no transcript or full tool results, uses no polling timer, and deletes a call immediately when it ends. Completed history belongs in Pi's session file. Live text/model streams without tool calls are not recorded by the tracker.
|
|
67
|
+
|
|
68
|
+
This is an instruction-based workflow, **not a sandbox**. Inherited tools and extensions retain their normal permissions. The side agent uses a separate session file; it never opens the main file as its writable session manager.
|
|
69
|
+
|
|
70
|
+
## Settings
|
|
71
|
+
|
|
72
|
+
On first use, Watcher creates:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
~/.pi/agent/pi-watcher.json
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
It respects `PI_CODING_AGENT_DIR`. Example:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"excludedExtensions": [],
|
|
83
|
+
"sessionDir": "pi-watcher-sessions"
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Optional initial model and thinking defaults:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"excludedExtensions": ["some-extension-package", "builtin:llama.cpp", "./extensions/unwanted.ts"],
|
|
92
|
+
"model": { "provider": "your-provider", "modelId": "your-model-id" },
|
|
93
|
+
"thinkingLevel": "medium"
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- `excludedExtensions`: package/extension names, paths, `npm:`/`git:` sources, or `builtin:<name>`. An excluded directory excludes its descendants. Relative `./` paths resolve from the agent directory. **Watcher itself is always excluded.**
|
|
98
|
+
- `sessionDir`: separate side-conversation storage; defaults to `<agent-dir>/pi-watcher-sessions`. Relative paths resolve from the agent directory.
|
|
99
|
+
- `model` and `thinkingLevel`: initial defaults for a **new** side conversation. A resumed conversation keeps its own saved selections. If omitted, Pi selects from the inherited settings.
|
|
100
|
+
|
|
101
|
+
Run `/reload` after editing settings. Watcher never changes Pi's global model defaults.
|
|
102
|
+
|
|
103
|
+
### Inheritance
|
|
104
|
+
|
|
105
|
+
The side agent is a separate **Pi RPC process**, running in the main session's working directory. It loads the normal CLI resources: extensions, custom providers, error handlers, MCP configuration, tools, skills, credentials, models, and trusted project settings. Resource/tool flags from the main CLI are inherited; main model/session/mode flags are not. Extensions listed in `excludedExtensions`, and Watcher itself, are filtered **before loading**.
|
|
106
|
+
|
|
107
|
+
Important boundaries:
|
|
108
|
+
|
|
109
|
+
- Extension code is reloaded, not copied with its in-memory state. Main-only runtime registrations or temporary tool/model changes are not automatically mirrored.
|
|
110
|
+
- RPC supports inherited confirmation/select/input dialogs; Watcher temporarily closes its overlay to show those using Pi's native UI. An inherited multiline editor request uses a cancellable single-line input. TUI-only custom components are unavailable in RPC mode.
|
|
111
|
+
- Inherited extensions can start their own processes, open brokers, or initiate work on startup. Watcher itself sends no startup prompt, but cannot promise that every inherited extension is idle. Exclude extensions whose startup behavior you do not want duplicated (especially exclusive browser/terminal integrations).
|
|
112
|
+
- Normal Pi cache-warming and provider settings are inherited. Provider requests/usage for the side agent are separate from main's totals.
|
|
113
|
+
- A one-off main CLI `--api-key` is not forwarded; use configured credentials for the chosen side model.
|
|
114
|
+
- Main sessions started with `--no-session` cannot be watched.
|
|
115
|
+
|
|
116
|
+
## Persistence and lifecycle
|
|
117
|
+
|
|
118
|
+
One side session is associated with each main session ID. Reopening the overlay resumes the same conversation; restarting Pi and resuming the same main session also restores it. `/new`, `/resume`, `/fork`, reload, and shutdown dispose the old child and its listeners. The next `/watcher` starts/resumes the appropriate side session.
|
|
119
|
+
|
|
120
|
+
Closing only the overlay leaves the side agent alive, including any inherited MCP/extension processes. Main-session history remains read-only input to the side agent. A lock prevents two Watcher runtimes from writing the same side session.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
npm ci
|
|
126
|
+
npm run check
|
|
127
|
+
npm test
|
|
128
|
+
npm pack --dry-run --ignore-scripts
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Tests use an offline scripted provider; no paid model calls are needed. They exercise the real RPC child, extension inheritance/exclusion, session resume, models, steering, main-extension integration, transport limits, live-tool cleanup, and overlay keyboard/rendering behavior.
|
|
132
|
+
|
|
133
|
+
The implementation is in `src/index.ts` (integration), `src/runtime*` (isolated child and RPC/IPC bridge), `src/live-tools.ts`, `src/config.ts`, and `src/ui*` (overlay).
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oira666_pi-watcher",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "A persistent side-agent conversation for observing and steering your running Pi agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "gee666",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/gee666/pi-watcher.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/gee666/pi-watcher#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/gee666/pi-watcher/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi",
|
|
19
|
+
"agent",
|
|
20
|
+
"watcher"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.19.0"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src/",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"watcher-settings.example.json"
|
|
30
|
+
],
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./src/index.ts"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"check": "tsc --noEmit",
|
|
38
|
+
"test": "node --test test/*.test.ts test/*.test.cjs",
|
|
39
|
+
"prepack": "npm run check && npm test"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-ai": "*",
|
|
43
|
+
"@earendil-works/pi-agent-core": "*",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "*"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@earendil-works/pi-ai": "1.0.4",
|
|
49
|
+
"@earendil-works/pi-agent-core": "1.0.4",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "1.0.4",
|
|
51
|
+
"@earendil-works/pi-tui": "1.0.4",
|
|
52
|
+
"@types/node": "^22.19.0",
|
|
53
|
+
"jiti": "^2.6.1",
|
|
54
|
+
"typescript": "^5.9.3"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import type { ThinkingLevel } from "./runtime.ts";
|
|
5
|
+
|
|
6
|
+
export interface WatcherSettings {
|
|
7
|
+
excludedExtensions: string[];
|
|
8
|
+
sessionDir: string;
|
|
9
|
+
model?: { provider: string; modelId: string };
|
|
10
|
+
thinkingLevel?: ThinkingLevel;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A separate user settings file; never edits Pi's settings or model defaults. */
|
|
14
|
+
export function loadSettings(agentDir: string): WatcherSettings {
|
|
15
|
+
const path = join(agentDir, "pi-watcher.json");
|
|
16
|
+
let raw: Record<string, unknown>;
|
|
17
|
+
try {
|
|
18
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
21
|
+
throw new Error(`Cannot read ${path}: ${String(error)}`);
|
|
22
|
+
}
|
|
23
|
+
mkdirSync(agentDir, { recursive: true });
|
|
24
|
+
raw = { excludedExtensions: [] };
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(path, `${JSON.stringify(raw, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return loadSettings(agentDir);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${path}: expected an object`);
|
|
33
|
+
const excluded = raw.excludedExtensions ?? [];
|
|
34
|
+
if (!Array.isArray(excluded) || excluded.some((x) => typeof x !== "string" || !x.trim())) {
|
|
35
|
+
throw new Error(`${path}: excludedExtensions must be an array of nonempty strings`);
|
|
36
|
+
}
|
|
37
|
+
const model = raw.model as WatcherSettings["model"];
|
|
38
|
+
if (model !== undefined && (!model || typeof model.provider !== "string" || !model.provider.trim() ||
|
|
39
|
+
typeof model.modelId !== "string" || !model.modelId.trim())) {
|
|
40
|
+
throw new Error(`${path}: model must contain provider and modelId`);
|
|
41
|
+
}
|
|
42
|
+
const levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
43
|
+
if (raw.thinkingLevel !== undefined && !levels.includes(raw.thinkingLevel as string)) {
|
|
44
|
+
throw new Error(`${path}: invalid thinkingLevel`);
|
|
45
|
+
}
|
|
46
|
+
if (raw.sessionDir !== undefined && (typeof raw.sessionDir !== "string" || !raw.sessionDir.trim())) {
|
|
47
|
+
throw new Error(`${path}: sessionDir must be a nonempty path`);
|
|
48
|
+
}
|
|
49
|
+
const expandHome = (p: string) => p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
|
|
50
|
+
return {
|
|
51
|
+
excludedExtensions: excluded.map((entry: string) => entry.startsWith(".") ? resolve(agentDir, entry) : entry),
|
|
52
|
+
sessionDir: resolve(agentDir, expandHome(raw.sessionDir as string ?? "pi-watcher-sessions")),
|
|
53
|
+
model,
|
|
54
|
+
thinkingLevel: raw.thinkingLevel as ThinkingLevel | undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadSettings } from "./config.ts";
|
|
3
|
+
import { LiveTools } from "./live-tools.ts";
|
|
4
|
+
import {
|
|
5
|
+
isWatcherChildProcess, messagesToTranscript, WatcherRuntime,
|
|
6
|
+
type UiDialogRequest, type UiDialogResponse, type WatcherRuntimeEvent,
|
|
7
|
+
} from "./runtime.ts";
|
|
8
|
+
import { openWatcherUI, WatcherStore, type WatcherController } from "./ui.ts";
|
|
9
|
+
|
|
10
|
+
interface Side {
|
|
11
|
+
id: string;
|
|
12
|
+
ctx: ExtensionContext;
|
|
13
|
+
runtime: WatcherRuntime;
|
|
14
|
+
store: WatcherStore;
|
|
15
|
+
controller: WatcherController;
|
|
16
|
+
ready: Promise<void>;
|
|
17
|
+
startupError?: string;
|
|
18
|
+
pendingQuestion?: { text: string };
|
|
19
|
+
lifetime: AbortController;
|
|
20
|
+
overlay?: AbortController;
|
|
21
|
+
dialogCount: number;
|
|
22
|
+
dialogQueue: Promise<unknown>;
|
|
23
|
+
unsubscribe?: () => void;
|
|
24
|
+
}
|
|
25
|
+
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
26
|
+
|
|
27
|
+
export default function watcher(pi: ExtensionAPI): void {
|
|
28
|
+
if (isWatcherChildProcess()) return;
|
|
29
|
+
const live = new LiveTools();
|
|
30
|
+
let current: ExtensionContext | undefined;
|
|
31
|
+
let side: Side | undefined;
|
|
32
|
+
let compacting = false;
|
|
33
|
+
|
|
34
|
+
function isCurrent(s: Side): boolean {
|
|
35
|
+
return side === s && !s.lifetime.signal.aborted && current?.sessionManager.getSessionId() === s.id;
|
|
36
|
+
}
|
|
37
|
+
function mainStatus(): void {
|
|
38
|
+
if (!side || !current) return;
|
|
39
|
+
side.store.setMain({ status: current.isIdle() ? "idle" : "running", activity: compacting ? "compacting" : live.summary() });
|
|
40
|
+
}
|
|
41
|
+
async function show(s: Side, initialAction?: "model"): Promise<void> {
|
|
42
|
+
if (!isCurrent(s) || s.dialogCount || s.store.state.open) return;
|
|
43
|
+
s.overlay = new AbortController();
|
|
44
|
+
const close = () => s.overlay?.abort();
|
|
45
|
+
s.lifetime.signal.addEventListener("abort", close, { once: true });
|
|
46
|
+
try {
|
|
47
|
+
await openWatcherUI(s.ctx, { store: s.store, controller: s.controller, title: "Watcher", signal: s.overlay.signal, initialAction });
|
|
48
|
+
} finally {
|
|
49
|
+
s.lifetime.signal.removeEventListener("abort", close);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Pi's inherited extension dialogs use its native UI. Close the watcher overlay first,
|
|
54
|
+
// serialize dialogs, and reopen it afterwards, never leaving two editors competing for focus.
|
|
55
|
+
async function extensionDialog(s: Side, request: UiDialogRequest, signal: AbortSignal): Promise<UiDialogResponse> {
|
|
56
|
+
const run = async (): Promise<UiDialogResponse> => {
|
|
57
|
+
if (!isCurrent(s) || signal.aborted) return { cancelled: true };
|
|
58
|
+
const reopen = s.store.state.open;
|
|
59
|
+
s.dialogCount++;
|
|
60
|
+
s.overlay?.abort();
|
|
61
|
+
await s.store.uiPromise;
|
|
62
|
+
const timeout = "timeout" in request && typeof request.timeout === "number" ? request.timeout : undefined;
|
|
63
|
+
const opts = { signal: AbortSignal.any([signal, s.lifetime.signal]), timeout };
|
|
64
|
+
const title = `Watcher: ${request.title}`;
|
|
65
|
+
try {
|
|
66
|
+
if (!isCurrent(s) || opts.signal.aborted) return { cancelled: true };
|
|
67
|
+
switch (request.method) {
|
|
68
|
+
case "confirm": return { confirmed: await s.ctx.ui.confirm(title, request.message, opts) };
|
|
69
|
+
case "select": {
|
|
70
|
+
const value = await s.ctx.ui.select(title, request.options, opts);
|
|
71
|
+
return value === undefined ? { cancelled: true } : { value };
|
|
72
|
+
}
|
|
73
|
+
case "input": {
|
|
74
|
+
const value = await s.ctx.ui.input(title, request.placeholder, opts);
|
|
75
|
+
return value === undefined ? { cancelled: true } : { value };
|
|
76
|
+
}
|
|
77
|
+
case "editor": {
|
|
78
|
+
// input supports cancellation, unlike Pi's native multi-line editor API.
|
|
79
|
+
const value = await s.ctx.ui.input(`${title} (suggested: ${request.prefill ?? ""})`, request.prefill, opts);
|
|
80
|
+
return value === undefined ? { cancelled: true } : { value };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
s.dialogCount--;
|
|
85
|
+
if (reopen && isCurrent(s)) void show(s).catch((e) => s.ctx.ui.notify(errorText(e), "error"));
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const result = s.dialogQueue.then(run, run);
|
|
89
|
+
s.dialogQueue = result.catch(() => {});
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function event(s: Side, incoming: WatcherRuntimeEvent): void {
|
|
94
|
+
if (!isCurrent(s)) return;
|
|
95
|
+
const store = s.store;
|
|
96
|
+
if (incoming.type === "session_event") {
|
|
97
|
+
const e = incoming.event;
|
|
98
|
+
switch (e.type) {
|
|
99
|
+
case "agent_start": store.setSide({ status: "running", error: undefined }); break;
|
|
100
|
+
case "message_update": {
|
|
101
|
+
const delta = e.assistantMessageEvent as { type?: string; delta?: string } | undefined;
|
|
102
|
+
if (delta?.type === "text_delta" && delta.delta) store.appendLive(delta.delta);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case "message_end": {
|
|
106
|
+
const message = e.message as { role?: string; errorMessage?: string } | undefined;
|
|
107
|
+
if (!message || message.role === "user") break; // UI already added the user's question.
|
|
108
|
+
if (message.role === "assistant") store.setLive(undefined);
|
|
109
|
+
for (const row of messagesToTranscript([message])) store.addMessage(row.role, row.text);
|
|
110
|
+
if (message.errorMessage) store.setSide({ error: message.errorMessage });
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
case "tool_execution_start": store.setSide({ activity: String(e.toolName).slice(0, 128) }); break;
|
|
114
|
+
case "tool_execution_end": store.setSide({ activity: undefined }); break;
|
|
115
|
+
case "auto_compaction_start": store.setSide({ activity: "compacting" }); break;
|
|
116
|
+
case "auto_retry_start": store.setSide({ activity: "retrying" }); break;
|
|
117
|
+
case "model_select": {
|
|
118
|
+
const model = e.model as { provider: string; id: string } | undefined;
|
|
119
|
+
if (model) store.setModel(model);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case "agent_settled":
|
|
123
|
+
store.endTurn({ error: store.state.side.error });
|
|
124
|
+
if (!store.state.open) s.ctx.ui.notify("Watcher reply ready — /watcher", "info");
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
} else if (incoming.type === "exit" && !incoming.expected) {
|
|
128
|
+
store.endTurn({ error: incoming.error ?? "Side agent exited. Reopen /watcher to resume." });
|
|
129
|
+
} else if (incoming.type === "extension_error") {
|
|
130
|
+
s.ctx.ui.notify(`Watcher extension: ${incoming.error ?? "unknown error"}`, "warning");
|
|
131
|
+
} else if (incoming.type === "warning") {
|
|
132
|
+
s.ctx.ui.notify(`Watcher: ${incoming.message}`, "warning");
|
|
133
|
+
} else if (incoming.type === "ui" && incoming.request.method === "notify") {
|
|
134
|
+
s.ctx.ui.notify(`Watcher: ${incoming.request.message}`, incoming.request.notifyType ?? "info");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function dispose(): Promise<void> {
|
|
139
|
+
const old = side;
|
|
140
|
+
side = undefined;
|
|
141
|
+
if (!old) return;
|
|
142
|
+
old.lifetime.abort();
|
|
143
|
+
old.overlay?.abort();
|
|
144
|
+
old.store.dispose();
|
|
145
|
+
old.unsubscribe?.();
|
|
146
|
+
await old.runtime.dispose();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function ensure(ctx: ExtensionContext): Promise<Side> {
|
|
150
|
+
current = ctx;
|
|
151
|
+
const id = ctx.sessionManager.getSessionId();
|
|
152
|
+
if (side && side.id === id && !side.startupError && side.runtime.status !== "exited" && side.runtime.status !== "disposed") {
|
|
153
|
+
return side;
|
|
154
|
+
}
|
|
155
|
+
await dispose();
|
|
156
|
+
const mainSessionFile = ctx.sessionManager.getSessionFile();
|
|
157
|
+
if (!mainSessionFile) throw new Error("Watcher needs a persisted main session (not --no-session).");
|
|
158
|
+
const settings = loadSettings(getAgentDir());
|
|
159
|
+
const runtime = new WatcherRuntime();
|
|
160
|
+
const store = new WatcherStore();
|
|
161
|
+
const s: Side = {
|
|
162
|
+
id, ctx, runtime, store, lifetime: new AbortController(), ready: Promise.resolve(),
|
|
163
|
+
dialogCount: 0, dialogQueue: Promise.resolve(),
|
|
164
|
+
controller: {
|
|
165
|
+
async submit(text) {
|
|
166
|
+
if (!isCurrent(s)) throw new Error("Main session changed. Reopen /watcher.");
|
|
167
|
+
const pending = { text };
|
|
168
|
+
s.pendingQuestion = pending;
|
|
169
|
+
await s.ready;
|
|
170
|
+
if (!isCurrent(s) || s.pendingQuestion !== pending) return;
|
|
171
|
+
s.pendingQuestion = undefined;
|
|
172
|
+
const disposition = await runtime.prompt(text);
|
|
173
|
+
if (disposition === "handled") store.endTurn();
|
|
174
|
+
},
|
|
175
|
+
async stop() {
|
|
176
|
+
if (s.pendingQuestion) {
|
|
177
|
+
s.pendingQuestion = undefined;
|
|
178
|
+
store.endTurn();
|
|
179
|
+
if (!store.state.model) store.setSide({ status: "starting" });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Abort alone can continue queued work in Pi. Clear only the SIDE queue first.
|
|
183
|
+
await runtime.clearQueue();
|
|
184
|
+
await runtime.abort();
|
|
185
|
+
store.endTurn({ aborted: true });
|
|
186
|
+
},
|
|
187
|
+
async listModels() { await s.ready; return runtime.getAvailableModels(); },
|
|
188
|
+
async setModel(model) {
|
|
189
|
+
await s.ready;
|
|
190
|
+
if (!isCurrent(s)) throw new Error("Main session changed. Reopen /watcher.");
|
|
191
|
+
await runtime.setModel(model.provider, model.id);
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
side = s;
|
|
196
|
+
s.unsubscribe = runtime.subscribe((e) => event(s, e));
|
|
197
|
+
mainStatus();
|
|
198
|
+
store.setSide({ status: "starting" });
|
|
199
|
+
s.ready = (async () => {
|
|
200
|
+
// Let the overlay mount before doing child discovery/spawn or waiting for extensions/MCPs.
|
|
201
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
202
|
+
if (!isCurrent(s)) return;
|
|
203
|
+
const info = await runtime.start({
|
|
204
|
+
cwd: ctx.cwd, mainSessionFile, mainSessionId: id,
|
|
205
|
+
sessionDir: settings.sessionDir, excludedExtensions: settings.excludedExtensions,
|
|
206
|
+
model: settings.model, thinkingLevel: settings.thinkingLevel,
|
|
207
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
208
|
+
getCurrentMainSessionId: () => current?.sessionManager.getSessionId(),
|
|
209
|
+
liveMainTools: ({ params }) => JSON.stringify({
|
|
210
|
+
mainSessionFile, mainSessionId: id, leafId: current?.sessionManager.getLeafId(),
|
|
211
|
+
state: compacting ? "compacting" : current?.isIdle() ? "idle" : "working",
|
|
212
|
+
...live.snapshot(params),
|
|
213
|
+
}),
|
|
214
|
+
steerMain: async ({ message, mode, rationale, signal }) => {
|
|
215
|
+
if (!isCurrent(s)) return { approved: false, reason: "Main session changed." };
|
|
216
|
+
const decision = await store.requestSteeringApproval({ text: message,
|
|
217
|
+
reason: `${rationale ?? "Message to main agent."} ${ctx.isIdle() ? "Main is idle: sending starts a new turn." : "Delivery waits for a safe turn boundary."}` }, signal);
|
|
218
|
+
if (decision.action !== "send" || signal.aborted || !isCurrent(s)) {
|
|
219
|
+
return { approved: false, reason: "Cancelled or main session changed." };
|
|
220
|
+
}
|
|
221
|
+
if (compacting) {
|
|
222
|
+
ctx.ui.notify("Watcher: main is compacting; message was NOT sent. Ask again after compaction.", "warning");
|
|
223
|
+
return { approved: false, reason: "Main is compacting; not sent. Ask the human before retrying." };
|
|
224
|
+
}
|
|
225
|
+
const text = `[Watcher — approved by you] ${decision.text}`;
|
|
226
|
+
pi.sendUserMessage(text, { deliverAs: mode });
|
|
227
|
+
store.addMessage("tool", `Sent to main (${mode}): ${text}`);
|
|
228
|
+
return { approved: true, deliveredMessage: text, mode };
|
|
229
|
+
},
|
|
230
|
+
onUiRequest: (request, { signal }) => extensionDialog(s, request, signal),
|
|
231
|
+
});
|
|
232
|
+
if (!isCurrent(s)) { await runtime.dispose(); return; }
|
|
233
|
+
const [messages, state] = await Promise.all([runtime.getMessages(), runtime.getState()]);
|
|
234
|
+
if (!isCurrent(s)) return;
|
|
235
|
+
store.setMessages(messagesToTranscript(messages));
|
|
236
|
+
// The user can submit while startup is in progress. Preserve that unsent question
|
|
237
|
+
// when replacing the initial empty display with the restored conversation.
|
|
238
|
+
if (s.pendingQuestion) store.addMessage("user", s.pendingQuestion.text);
|
|
239
|
+
store.setModel(state.model ?? undefined);
|
|
240
|
+
store.setSide({ status: runtime.isBusy || s.pendingQuestion ? "running" : "idle" });
|
|
241
|
+
for (const warning of info.warnings) ctx.ui.notify(`Watcher: ${warning}`, "warning");
|
|
242
|
+
})();
|
|
243
|
+
// Observe failures even when no question/model request awaits readiness. Keep the
|
|
244
|
+
// panel open so errors are visible, and allow the next /watcher to retry startup.
|
|
245
|
+
void s.ready.catch((error) => {
|
|
246
|
+
if (!isCurrent(s)) return;
|
|
247
|
+
s.startupError = errorText(error);
|
|
248
|
+
s.pendingQuestion = undefined;
|
|
249
|
+
store.endTurn({ error: s.startupError });
|
|
250
|
+
ctx.ui.notify(`Watcher: ${s.startupError}`, "error");
|
|
251
|
+
void runtime.dispose();
|
|
252
|
+
});
|
|
253
|
+
return s;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
257
|
+
await dispose();
|
|
258
|
+
current = ctx;
|
|
259
|
+
compacting = false;
|
|
260
|
+
live.clear();
|
|
261
|
+
});
|
|
262
|
+
pi.on("session_shutdown", async () => { current = undefined; live.clear(); await dispose(); });
|
|
263
|
+
pi.on("agent_start", () => mainStatus());
|
|
264
|
+
pi.on("agent_settled", () => { compacting = false; live.clear(); mainStatus(); });
|
|
265
|
+
pi.on("tool_execution_start", (e) => { live.start(e); mainStatus(); });
|
|
266
|
+
pi.on("tool_execution_update", (e) => { live.update(e); });
|
|
267
|
+
pi.on("tool_execution_end", (e) => { live.end(e.toolCallId); mainStatus(); });
|
|
268
|
+
pi.on("session_before_compact", () => { compacting = true; mainStatus(); });
|
|
269
|
+
pi.on("session_compact", () => { compacting = false; mainStatus(); });
|
|
270
|
+
pi.on("session_compact_failed", () => { compacting = false; mainStatus(); });
|
|
271
|
+
|
|
272
|
+
pi.registerCommand("watcher", {
|
|
273
|
+
description: "Open the side agent; /watcher <question> to ask; /watcher model to choose its model",
|
|
274
|
+
handler: async (args, ctx) => {
|
|
275
|
+
if (ctx.mode !== "tui") { ctx.ui.notify("/watcher is a human-only interactive terminal command.", "warning"); return; }
|
|
276
|
+
try {
|
|
277
|
+
const s = await ensure(ctx);
|
|
278
|
+
const question = args.trim();
|
|
279
|
+
if (question && question !== "model") {
|
|
280
|
+
if (s.runtime.isBusy || s.store.state.side.status === "running") {
|
|
281
|
+
s.store.setDraft(question);
|
|
282
|
+
ctx.ui.notify("Watcher is replying; your question is saved as a draft.", "info");
|
|
283
|
+
} else {
|
|
284
|
+
s.store.beginTurn(question);
|
|
285
|
+
// Open UI immediately; inherited permission dialogs may need it during preflight.
|
|
286
|
+
void Promise.resolve(s.controller.submit(question)).catch((e) => s.store.endTurn({ error: errorText(e) }));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
await show(s, question === "model" ? "model" : undefined);
|
|
290
|
+
} catch (error) { ctx.ui.notify(`Watcher: ${errorText(error)}`, "error"); }
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Only in-flight metadata. No transcript, result buffers, polling, or timers. */
|
|
2
|
+
export const MAX_LIVE_TOOLS = 64;
|
|
3
|
+
const MAX_PREVIEW = 320;
|
|
4
|
+
interface LiveTool {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
parentId?: string;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
updatedAt?: number;
|
|
10
|
+
input?: string;
|
|
11
|
+
output?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function inputPreview(args: unknown): string | undefined {
|
|
15
|
+
if (!args || typeof args !== "object") return undefined;
|
|
16
|
+
const fields = args as Record<string, unknown>;
|
|
17
|
+
// Do not stringify arbitrary arguments or keep references to them.
|
|
18
|
+
for (const key of ["command", "path", "query", "task", "action"]) {
|
|
19
|
+
if (typeof fields[key] === "string") return `${key}: ${fields[key].slice(0, MAX_PREVIEW)}`;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class LiveTools {
|
|
25
|
+
private calls = new Map<string, LiveTool>();
|
|
26
|
+
get size(): number { return this.calls.size; }
|
|
27
|
+
start(event: { toolCallId: string; toolName: string; parentToolCallId?: string; args?: unknown }): void {
|
|
28
|
+
if (this.calls.size >= MAX_LIVE_TOOLS && !this.calls.has(event.toolCallId)) return;
|
|
29
|
+
this.calls.set(event.toolCallId, {
|
|
30
|
+
id: event.toolCallId, name: event.toolName.slice(0, 128), parentId: event.parentToolCallId,
|
|
31
|
+
startedAt: Date.now(), input: inputPreview(event.args),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
update(event: { toolCallId: string; partialResult?: unknown }): void {
|
|
35
|
+
const call = this.calls.get(event.toolCallId);
|
|
36
|
+
if (!call) return;
|
|
37
|
+
call.updatedAt = Date.now();
|
|
38
|
+
const result = event.partialResult as { content?: Array<{ type?: string; text?: string }> } | undefined;
|
|
39
|
+
if (!Array.isArray(result?.content)) return;
|
|
40
|
+
// Keep only a short tail from one text block, replacing the previous preview.
|
|
41
|
+
for (let i = result.content.length - 1; i >= 0; i--) {
|
|
42
|
+
const part = result.content[i];
|
|
43
|
+
if (part?.type === "text" && typeof part.text === "string") {
|
|
44
|
+
call.output = part.text.slice(-MAX_PREVIEW);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
end(id: string): void { this.calls.delete(id); }
|
|
50
|
+
clear(): void { this.calls.clear(); }
|
|
51
|
+
summary(): string | undefined {
|
|
52
|
+
if (!this.calls.size) return undefined;
|
|
53
|
+
return [...this.calls.values()].slice(0, 3).map((c) => c.name).join(", ") +
|
|
54
|
+
(this.calls.size > 3 ? ` (+${this.calls.size - 3})` : "");
|
|
55
|
+
}
|
|
56
|
+
snapshot(params: { toolCallId?: string; limit?: number } = {}) {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const limit = Number.isFinite(params.limit) ? Math.max(1, Math.min(MAX_LIVE_TOOLS, Math.floor(params.limit!))) : 16;
|
|
59
|
+
const calls = params.toolCallId ? [this.calls.get(params.toolCallId)].filter((v): v is LiveTool => !!v) : [...this.calls.values()];
|
|
60
|
+
return {
|
|
61
|
+
asOf: new Date(now).toISOString(),
|
|
62
|
+
note: `Only currently running tools; completed calls are removed immediately. At most ${MAX_LIVE_TOOLS} concurrent calls are tracked. Read the main session file for history.`,
|
|
63
|
+
tools: calls.slice(0, limit).map((c) => ({ ...c, elapsedMs: now - c.startedAt })),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|