pi-note 0.33.4 → 0.35.0
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/README.md +45 -2
- package/dist-mcp/mcp/events.js +150 -0
- package/dist-mcp/mcp/http.js +7 -2
- package/mcp/README.md +57 -3
- package/mcp/events.ts +116 -0
- package/mcp/http.ts +5 -1
- package/package.json +3 -2
- package/remote/completion.ts +50 -0
- package/remote/session.ts +17 -4
- package/remote.ts +5 -1
package/README.md
CHANGED
|
@@ -92,12 +92,55 @@ missing/stale revisions fail closed. Resume, reload and `/new` never replay the
|
|
|
92
92
|
handoff or replace another selection. Pi keeps its normal endpoint/token settings.
|
|
93
93
|
With pi-note 0.33.4+, completing the launched task from the menu, footer or
|
|
94
94
|
**Complete task and exit** exits Pi directly, so Kitty closes its dedicated tab
|
|
95
|
-
(or the standalone window's last tab).
|
|
96
|
-
|
|
95
|
+
(or the standalone window's last tab). Pinote itself never runs Git commands;
|
|
96
|
+
optional local completion hooks can briefly delay closing (see below).
|
|
97
97
|
This ownership survives `/reload`, but not `/new`, resume, fork, tree navigation
|
|
98
98
|
or process restart. Completing a different task or an externally completed task
|
|
99
99
|
does not close Pi; failed/uncertain completion stays open.
|
|
100
100
|
|
|
101
|
+
### Local completion event (pi-note 0.35.0+)
|
|
102
|
+
|
|
103
|
+
Trusted sibling extensions can listen on `pi.events` for `pinote:task-completed`:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
pi.events.on('pinote:task-completed', (event) => {
|
|
107
|
+
const { taskId, signal, waitUntil } = event as {
|
|
108
|
+
taskId: number;
|
|
109
|
+
signal: AbortSignal;
|
|
110
|
+
waitUntil(work: Promise<unknown>): void;
|
|
111
|
+
};
|
|
112
|
+
waitUntil(Promise.resolve().then(() => doLocalWork(taskId, signal))); // Register before any await.
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
This local event fires exactly once only after this client's acknowledged **Done**
|
|
117
|
+
action clears a valid selected task, across menu, footer and exit/new-session paths.
|
|
118
|
+
`taskId` is the original positive safe-integer task ID, not the cleared selection.
|
|
119
|
+
External completions, notifications, clear-selection actions and failed/stale Done
|
|
120
|
+
attempts do not emit it. Pinote does not submit a model prompt for this event.
|
|
121
|
+
|
|
122
|
+
Pi's event bus is synchronous fire-and-forget: returning an async handler's promise
|
|
123
|
+
is insufficient. Call `waitUntil` during dispatch; late registrations are ignored.
|
|
124
|
+
Put potentially throwing work inside the registered promise, as above. Exceptions
|
|
125
|
+
thrown by the listener itself before registration are handled by Pi's own logger,
|
|
126
|
+
which may print their raw text; Pinote cannot intercept those bus errors.
|
|
127
|
+
All registered work is awaited outside Pinote's serial queue (so listeners may use
|
|
128
|
+
`pinote:query-current`) before Done returns or a launched session auto-exits.
|
|
129
|
+
Without registered work, completion proceeds immediately. Ordinary Done still
|
|
130
|
+
keeps the conversation open.
|
|
131
|
+
|
|
132
|
+
There is one fixed **25-second total deadline**, shared by all listeners. The signal
|
|
133
|
+
aborts on session lifetime cancellation, deadline, or hook settlement; listeners
|
|
134
|
+
must honor it and avoid blocking synchronous work. Timers cannot preempt JavaScript
|
|
135
|
+
that blocks the event loop. On runtime teardown, `signal.reason` carries Pi's
|
|
136
|
+
shutdown reason (`quit`, `reload`, `new`, `resume`, or `fork`). A listener with its
|
|
137
|
+
own awaited shutdown handler may finish bounded work on `quit`; other cancellation
|
|
138
|
+
still stops its work. Hook rejection, timeout or cancellation never undo an
|
|
139
|
+
acknowledged completion, invalidate its state, or retry remote writes. Registered
|
|
140
|
+
promise rejections and timeouts produce only a generic warning when UI is available;
|
|
141
|
+
Pinote does not display their error or response text. This is a trusted local extension API, not remote task
|
|
142
|
+
instructions or authorization to run models or external actions.
|
|
143
|
+
|
|
101
144
|
### Agent tools and context
|
|
102
145
|
|
|
103
146
|
The extension registers `pinote_search`, `pinote_get_task`, `pinote_get_current`,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const exec = promisify(execFile);
|
|
4
|
+
const LIMIT = 256;
|
|
5
|
+
const safe = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
6
|
+
/** One bounded CLI scan per tick, shared by all subscribers; no in-memory replay queue. */
|
|
7
|
+
export function completionEvents(log) {
|
|
8
|
+
const subscribers = new Set();
|
|
9
|
+
let timer;
|
|
10
|
+
let running;
|
|
11
|
+
let controller;
|
|
12
|
+
let stopped = false;
|
|
13
|
+
const reject = (res, status, message) => {
|
|
14
|
+
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
|
15
|
+
res.end(`${message}\n`);
|
|
16
|
+
};
|
|
17
|
+
const drop = (sub) => {
|
|
18
|
+
subscribers.delete(sub);
|
|
19
|
+
if (!subscribers.size) {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
timer = undefined;
|
|
22
|
+
controller?.abort();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const write = (sub, frame) => {
|
|
26
|
+
// Disconnect rather than queue unbounded data. Resume from the last persisted ID.
|
|
27
|
+
if (!sub.res.write(frame)) {
|
|
28
|
+
drop(sub);
|
|
29
|
+
sub.res.destroy();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const checkpoint = (sub, id) => {
|
|
33
|
+
write(sub, `id: ${id}\nevent: checkpoint\ndata: {}\n\n`);
|
|
34
|
+
sub.cursor = id;
|
|
35
|
+
};
|
|
36
|
+
const read = async (after) => {
|
|
37
|
+
const { stdout } = await exec(process.env.PINOTE_NOTE_COMMAND || 'note', ['--no-notify', 'events', '--limit', String(LIMIT), ...(after === undefined ? [] : ['--after', String(after)])], { signal: controller.signal, timeout: 10_000, maxBuffer: 256 * 1024 });
|
|
38
|
+
const page = JSON.parse(stdout);
|
|
39
|
+
if (!safe(page.cursor) || after !== undefined && page.cursor < after || !Array.isArray(page.events) || page.events.length > LIMIT)
|
|
40
|
+
throw new Error('Invalid event page');
|
|
41
|
+
let previous = after ?? 0;
|
|
42
|
+
for (const event of page.events) {
|
|
43
|
+
if (!safe(event.id) || event.id <= previous || event.id > page.cursor || !safe(event.task_id) || !event.task_id || typeof event.timestamp !== 'string' || event.timestamp.length > 128 || /[\r\n]/.test(event.timestamp))
|
|
44
|
+
throw new Error('Invalid completion');
|
|
45
|
+
previous = event.id;
|
|
46
|
+
}
|
|
47
|
+
return page;
|
|
48
|
+
};
|
|
49
|
+
const tick = async () => {
|
|
50
|
+
controller = new AbortController();
|
|
51
|
+
try {
|
|
52
|
+
const pending = [...subscribers].filter((sub) => !sub.ready);
|
|
53
|
+
if (pending.length) {
|
|
54
|
+
const tail = (await read()).cursor;
|
|
55
|
+
// New arrivals must not be validated against a tail captured before they joined.
|
|
56
|
+
for (const sub of pending) {
|
|
57
|
+
if (!subscribers.has(sub))
|
|
58
|
+
continue;
|
|
59
|
+
if (sub.cursor !== undefined && sub.cursor > tail) {
|
|
60
|
+
drop(sub);
|
|
61
|
+
reject(sub.res, 400, 'Last-Event-ID is ahead of current event history');
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
sub.res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
|
|
65
|
+
sub.ready = true;
|
|
66
|
+
checkpoint(sub, sub.cursor ?? tail);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!subscribers.size || stopped)
|
|
70
|
+
return;
|
|
71
|
+
const ready = [...subscribers].filter((sub) => sub.ready);
|
|
72
|
+
if (!ready.length)
|
|
73
|
+
return;
|
|
74
|
+
const after = Math.min(...ready.map((sub) => sub.cursor));
|
|
75
|
+
const page = await read(after);
|
|
76
|
+
for (const sub of ready) {
|
|
77
|
+
if (!subscribers.has(sub))
|
|
78
|
+
continue;
|
|
79
|
+
for (const event of page.events) {
|
|
80
|
+
if (event.id <= sub.cursor)
|
|
81
|
+
continue;
|
|
82
|
+
write(sub, `id: ${event.id}\nevent: task.completed\ndata: ${JSON.stringify({ task_id: event.task_id, timestamp: event.timestamp })}\n\n`);
|
|
83
|
+
sub.cursor = event.id;
|
|
84
|
+
if (!subscribers.has(sub))
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (!subscribers.has(sub))
|
|
88
|
+
continue;
|
|
89
|
+
if (page.cursor > sub.cursor)
|
|
90
|
+
checkpoint(sub, page.cursor);
|
|
91
|
+
if (!subscribers.has(sub))
|
|
92
|
+
continue;
|
|
93
|
+
if (Date.now() - sub.heartbeat >= 15_000) {
|
|
94
|
+
write(sub, ': heartbeat\n\n');
|
|
95
|
+
sub.heartbeat = Date.now();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
if (subscribers.size && !stopped)
|
|
101
|
+
log();
|
|
102
|
+
for (const sub of subscribers) {
|
|
103
|
+
if (!sub.res.headersSent)
|
|
104
|
+
reject(sub.res, 503, 'Event history unavailable');
|
|
105
|
+
else
|
|
106
|
+
sub.res.end();
|
|
107
|
+
}
|
|
108
|
+
subscribers.clear();
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
controller = undefined;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const schedule = () => {
|
|
115
|
+
if (stopped || running || !subscribers.size)
|
|
116
|
+
return;
|
|
117
|
+
running = tick().finally(() => {
|
|
118
|
+
running = undefined;
|
|
119
|
+
if (!stopped && subscribers.size)
|
|
120
|
+
timer = setTimeout(() => { timer = undefined; schedule(); }, 1000);
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
handle(req, res) {
|
|
125
|
+
if (req.method !== 'GET') {
|
|
126
|
+
res.setHeader('Allow', 'GET');
|
|
127
|
+
return reject(res, 405, 'GET required');
|
|
128
|
+
}
|
|
129
|
+
const raw = req.headers['last-event-id'];
|
|
130
|
+
if (raw !== undefined && (typeof raw !== 'string' || !/^[0-9]{1,16}$/.test(raw) || !safe(Number(raw))))
|
|
131
|
+
return reject(res, 400, 'Last-Event-ID must be a nonnegative safe integer');
|
|
132
|
+
if (stopped || subscribers.size >= 64)
|
|
133
|
+
return reject(res, 503, 'Event stream limit reached');
|
|
134
|
+
const sub = { res, cursor: raw === undefined ? undefined : Number(raw), ready: false, heartbeat: Date.now() };
|
|
135
|
+
subscribers.add(sub);
|
|
136
|
+
res.once('close', () => drop(sub));
|
|
137
|
+
if (!timer)
|
|
138
|
+
schedule();
|
|
139
|
+
},
|
|
140
|
+
async close() {
|
|
141
|
+
stopped = true;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
controller?.abort();
|
|
144
|
+
for (const sub of subscribers)
|
|
145
|
+
sub.res.end();
|
|
146
|
+
subscribers.clear();
|
|
147
|
+
await running;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
package/dist-mcp/mcp/http.js
CHANGED
|
@@ -7,6 +7,7 @@ import { isIP } from 'node:net';
|
|
|
7
7
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
8
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import { createPinoteServer } from "./core.js";
|
|
10
|
+
import { completionEvents } from "./events.js";
|
|
10
11
|
import { pinoteToken } from "../token.js";
|
|
11
12
|
function diagnostic(path, event) {
|
|
12
13
|
const line = `${new Date().toISOString()} Pinote HTTP ${event}\n`;
|
|
@@ -37,6 +38,7 @@ export async function startPinoteHttpServer(options = {}) {
|
|
|
37
38
|
// Fail startup if the configured log is not writable.
|
|
38
39
|
appendFileSync(logPath, '', { mode: 0o600 });
|
|
39
40
|
const log = (event) => diagnostic(logPath, event);
|
|
41
|
+
const events = completionEvents(() => log('event polling failed'));
|
|
40
42
|
const digest = (value) => createHash('sha256').update(value).digest();
|
|
41
43
|
const expected = digest(`Bearer ${token}`);
|
|
42
44
|
const sessions = new Map();
|
|
@@ -66,10 +68,12 @@ export async function startPinoteHttpServer(options = {}) {
|
|
|
66
68
|
return reject(res, 403);
|
|
67
69
|
if (req.headers.origin !== undefined && !options.allowedOrigins?.includes(req.headers.origin))
|
|
68
70
|
return reject(res, 403);
|
|
69
|
-
if (req.url !== '/mcp')
|
|
70
|
-
return reject(res, 404);
|
|
71
71
|
if (stopping)
|
|
72
72
|
return reject(res, 503);
|
|
73
|
+
if (req.url === '/events')
|
|
74
|
+
return events.handle(req, res);
|
|
75
|
+
if (req.url !== '/mcp')
|
|
76
|
+
return reject(res, 404);
|
|
73
77
|
if (!['GET', 'POST', 'DELETE'].includes(req.method ?? '')) {
|
|
74
78
|
res.setHeader('Allow', 'GET, POST, DELETE');
|
|
75
79
|
return reject(res, 405);
|
|
@@ -188,6 +192,7 @@ export async function startPinoteHttpServer(options = {}) {
|
|
|
188
192
|
const closed = new Promise((ok) => listener.close(() => ok()));
|
|
189
193
|
const force = setTimeout(() => listener.closeAllConnections(), 3000);
|
|
190
194
|
try {
|
|
195
|
+
await events.close();
|
|
191
196
|
await Promise.allSettled([...all].map((session) => session.server.close()));
|
|
192
197
|
await closed;
|
|
193
198
|
}
|
package/mcp/README.md
CHANGED
|
@@ -18,6 +18,11 @@ export PINOTE_NOTE_COMMAND="$PWD/.venv/bin/note"
|
|
|
18
18
|
npm --prefix pi run serve
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
After building, production installs may prune development/peer-only packages:
|
|
22
|
+
`npm --prefix pi prune --omit=dev --omit=peer --ignore-scripts`.
|
|
23
|
+
Run `npm --prefix pi run check:runtime` before restarting the service; its required
|
|
24
|
+
terminal-width helpers are runtime dependencies, not development-only packages.
|
|
25
|
+
|
|
21
26
|
The bearer token is mandatory, including on loopback: use at least 32 random
|
|
22
27
|
non-whitespace characters. Store it in an owner-only regular file (mode `0600`),
|
|
23
28
|
never commit or paste it into a conversation. For a new setup, after creating the
|
|
@@ -52,7 +57,7 @@ share. Point every client at the service instead.
|
|
|
52
57
|
|
|
53
58
|
The Node listener is plaintext HTTP. **Never expose it directly to the public
|
|
54
59
|
Internet.** Use a private VPN or a trusted HTTPS reverse proxy. The proxy must
|
|
55
|
-
forward `/mcp`, bearer authorization, MCP headers and streaming responses without
|
|
60
|
+
forward `/mcp` and `/events`, bearer authorization, MCP headers and streaming responses without
|
|
56
61
|
buffering, preserve the external Host, and allow long-lived SSE connections.
|
|
57
62
|
|
|
58
63
|
For example, a local reverse proxy serving `https://notes.example.net/mcp` forwards
|
|
@@ -113,8 +118,8 @@ serialize within this daemon and mutations use database/config revisions.
|
|
|
113
118
|
|
|
114
119
|
The service bounds request bodies and session counts, and expires idle disconnected
|
|
115
120
|
sessions. Active streams and operations are not expired. Shutdown closes sessions;
|
|
116
|
-
a server restart preserves notes but requires clients to reconnect.
|
|
117
|
-
replay
|
|
121
|
+
a server restart preserves notes but requires clients to reconnect. MCP notifications have no durable
|
|
122
|
+
replay; offline writes and automatic mutation retries are not provided. After an
|
|
118
123
|
interrupted write, read fresh state before retrying: it may already have committed.
|
|
119
124
|
|
|
120
125
|
The native terminal UI protocol and `pinote://agent-context` resource are internal
|
|
@@ -135,3 +140,52 @@ npm --prefix pi test
|
|
|
135
140
|
Tests use temporary data and real HTTP/stdio connections, including authentication,
|
|
136
141
|
shared notes, independent selections, conflicts and reconnects. Visual checks use
|
|
137
142
|
Xvfb and private D-Bus; never point them at personal data.
|
|
143
|
+
|
|
144
|
+
## Task-completion events
|
|
145
|
+
|
|
146
|
+
`GET /events` on the same listener requires the same bearer token, Host allowlist
|
|
147
|
+
and Origin policy as `/mcp`; it does not require an MCP session. Only GET is
|
|
148
|
+
accepted (405 otherwise). Use `Accept: text/event-stream` and disable proxy buffering.
|
|
149
|
+
The service requires `note` 0.21.0+ via `PINOTE_NOTE_COMMAND` (or `note` on PATH).
|
|
150
|
+
|
|
151
|
+
With no `Last-Event-ID`, subscription starts at the current durable history tail,
|
|
152
|
+
not with historical replay. Persist the numeric ID from **both** event types:
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
id: 0
|
|
156
|
+
event: checkpoint
|
|
157
|
+
data: {}
|
|
158
|
+
|
|
159
|
+
id: 3
|
|
160
|
+
event: task.completed
|
|
161
|
+
data: {"task_id":1,"timestamp":"2030-01-02T03:04:05.000000+00:00"}
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
An initial checkpoint is always sent, even at cursor 0. Completion IDs are event
|
|
166
|
+
row IDs, not task IDs. Only committed `done` transitions emit completions, whether
|
|
167
|
+
made by CLI, GUI or Pi. Re-marking done, starting/removing tasks and importing
|
|
168
|
+
archived notes do not. Checkpoints advance past unrelated history; idle streams
|
|
169
|
+
receive heartbeat comments every approximately 15 seconds.
|
|
170
|
+
|
|
171
|
+
Reconnect with `Last-Event-ID: <persisted ID>` to scan subsequent durable history.
|
|
172
|
+
Delivery is **at-least-once**, not exactly once; consumers should deduplicate by
|
|
173
|
+
event ID. IDs must be nonnegative decimal integers no greater than
|
|
174
|
+
9007199254740991. Malformed or future cursors return 400 with a plain-text error
|
|
175
|
+
before streaming begins; unavailable history returns 503. Database replacement or
|
|
176
|
+
restoring an older backup can invalidate a saved cursor and requires an explicit
|
|
177
|
+
consumer decision to establish a fresh baseline.
|
|
178
|
+
|
|
179
|
+
At most 64 streams (including pending subscriptions) are allowed; excess streams
|
|
180
|
+
return 503. One shared poller scans at most 256 history rows per roughly one-second
|
|
181
|
+
tick using the CLI, with one additional tail read when accepting new subscribers.
|
|
182
|
+
Catchup therefore has bounded throughput. Slow writers are disconnected rather
|
|
183
|
+
than buffered indefinitely and must reconnect. Poll failures close streams; the
|
|
184
|
+
last disconnect stops polling, and service shutdown aborts the CLI and ends streams.
|
|
185
|
+
|
|
186
|
+
For local integrations, `note events [--after CURSOR] [--limit N]` returns
|
|
187
|
+
`{"cursor":number,"events":[{"id":number,"task_id":number,"timestamp":string}]}`.
|
|
188
|
+
Without `--after` it returns the current tail and an empty array. With `--after`
|
|
189
|
+
(including 0), it scans up to N rows in ascending order (default 256, range 1–1000),
|
|
190
|
+
includes only completions, and advances the cursor across all scanned rows.
|
|
191
|
+
It is a read-only operation with the same safe cursor range and future-cursor rejection.
|
package/mcp/events.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
|
+
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
const LIMIT = 256;
|
|
7
|
+
type Page = { cursor: number; events: { id: number; task_id: number; timestamp: string }[] };
|
|
8
|
+
type Subscriber = { res: ServerResponse; cursor: number | undefined; ready: boolean; heartbeat: number };
|
|
9
|
+
const safe = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
10
|
+
|
|
11
|
+
/** One bounded CLI scan per tick, shared by all subscribers; no in-memory replay queue. */
|
|
12
|
+
export function completionEvents(log: () => void) {
|
|
13
|
+
const subscribers = new Set<Subscriber>();
|
|
14
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
15
|
+
let running: Promise<void> | undefined;
|
|
16
|
+
let controller: AbortController | undefined;
|
|
17
|
+
let stopped = false;
|
|
18
|
+
const reject = (res: ServerResponse, status: number, message: string) => {
|
|
19
|
+
res.writeHead(status, { 'Content-Type': 'text/plain' }); res.end(`${message}\n`);
|
|
20
|
+
};
|
|
21
|
+
const drop = (sub: Subscriber) => {
|
|
22
|
+
subscribers.delete(sub);
|
|
23
|
+
if (!subscribers.size) { clearTimeout(timer); timer = undefined; controller?.abort(); }
|
|
24
|
+
};
|
|
25
|
+
const write = (sub: Subscriber, frame: string) => {
|
|
26
|
+
// Disconnect rather than queue unbounded data. Resume from the last persisted ID.
|
|
27
|
+
if (!sub.res.write(frame)) { drop(sub); sub.res.destroy(); }
|
|
28
|
+
};
|
|
29
|
+
const checkpoint = (sub: Subscriber, id: number) => {
|
|
30
|
+
write(sub, `id: ${id}\nevent: checkpoint\ndata: {}\n\n`);
|
|
31
|
+
sub.cursor = id;
|
|
32
|
+
};
|
|
33
|
+
const read = async (after?: number): Promise<Page> => {
|
|
34
|
+
const { stdout } = await exec(process.env.PINOTE_NOTE_COMMAND || 'note',
|
|
35
|
+
['--no-notify', 'events', '--limit', String(LIMIT), ...(after === undefined ? [] : ['--after', String(after)])],
|
|
36
|
+
{ signal: controller!.signal, timeout: 10_000, maxBuffer: 256 * 1024 });
|
|
37
|
+
const page: Page = JSON.parse(stdout);
|
|
38
|
+
if (!safe(page.cursor) || after !== undefined && page.cursor < after || !Array.isArray(page.events) || page.events.length > LIMIT) throw new Error('Invalid event page');
|
|
39
|
+
let previous = after ?? 0;
|
|
40
|
+
for (const event of page.events) {
|
|
41
|
+
if (!safe(event.id) || event.id <= previous || event.id > page.cursor || !safe(event.task_id) || !event.task_id || typeof event.timestamp !== 'string' || event.timestamp.length > 128 || /[\r\n]/.test(event.timestamp)) throw new Error('Invalid completion');
|
|
42
|
+
previous = event.id;
|
|
43
|
+
}
|
|
44
|
+
return page;
|
|
45
|
+
};
|
|
46
|
+
const tick = async () => {
|
|
47
|
+
controller = new AbortController();
|
|
48
|
+
try {
|
|
49
|
+
const pending = [...subscribers].filter((sub) => !sub.ready);
|
|
50
|
+
if (pending.length) {
|
|
51
|
+
const tail = (await read()).cursor;
|
|
52
|
+
// New arrivals must not be validated against a tail captured before they joined.
|
|
53
|
+
for (const sub of pending) {
|
|
54
|
+
if (!subscribers.has(sub)) continue;
|
|
55
|
+
if (sub.cursor !== undefined && sub.cursor > tail) {
|
|
56
|
+
drop(sub); reject(sub.res, 400, 'Last-Event-ID is ahead of current event history'); continue;
|
|
57
|
+
}
|
|
58
|
+
sub.res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
|
|
59
|
+
sub.ready = true;
|
|
60
|
+
checkpoint(sub, sub.cursor ?? tail);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!subscribers.size || stopped) return;
|
|
64
|
+
const ready = [...subscribers].filter((sub) => sub.ready);
|
|
65
|
+
if (!ready.length) return;
|
|
66
|
+
const after = Math.min(...ready.map((sub) => sub.cursor!));
|
|
67
|
+
const page = await read(after);
|
|
68
|
+
for (const sub of ready) {
|
|
69
|
+
if (!subscribers.has(sub)) continue;
|
|
70
|
+
for (const event of page.events) {
|
|
71
|
+
if (event.id <= sub.cursor!) continue;
|
|
72
|
+
write(sub, `id: ${event.id}\nevent: task.completed\ndata: ${JSON.stringify({ task_id: event.task_id, timestamp: event.timestamp })}\n\n`);
|
|
73
|
+
sub.cursor = event.id;
|
|
74
|
+
if (!subscribers.has(sub)) break;
|
|
75
|
+
}
|
|
76
|
+
if (!subscribers.has(sub)) continue;
|
|
77
|
+
if (page.cursor > sub.cursor!) checkpoint(sub, page.cursor);
|
|
78
|
+
if (!subscribers.has(sub)) continue;
|
|
79
|
+
if (Date.now() - sub.heartbeat >= 15_000) { write(sub, ': heartbeat\n\n'); sub.heartbeat = Date.now(); }
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
if (subscribers.size && !stopped) log();
|
|
83
|
+
for (const sub of subscribers) {
|
|
84
|
+
if (!sub.res.headersSent) reject(sub.res, 503, 'Event history unavailable'); else sub.res.end();
|
|
85
|
+
}
|
|
86
|
+
subscribers.clear();
|
|
87
|
+
} finally { controller = undefined; }
|
|
88
|
+
};
|
|
89
|
+
const schedule = () => {
|
|
90
|
+
if (stopped || running || !subscribers.size) return;
|
|
91
|
+
running = tick().finally(() => {
|
|
92
|
+
running = undefined;
|
|
93
|
+
if (!stopped && subscribers.size) timer = setTimeout(() => { timer = undefined; schedule(); }, 1000);
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
handle(req: IncomingMessage, res: ServerResponse) {
|
|
98
|
+
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return reject(res, 405, 'GET required'); }
|
|
99
|
+
const raw = req.headers['last-event-id'];
|
|
100
|
+
if (raw !== undefined && (typeof raw !== 'string' || !/^[0-9]{1,16}$/.test(raw) || !safe(Number(raw)))) return reject(res, 400, 'Last-Event-ID must be a nonnegative safe integer');
|
|
101
|
+
if (stopped || subscribers.size >= 64) return reject(res, 503, 'Event stream limit reached');
|
|
102
|
+
const sub: Subscriber = { res, cursor: raw === undefined ? undefined : Number(raw), ready: false, heartbeat: Date.now() };
|
|
103
|
+
subscribers.add(sub);
|
|
104
|
+
res.once('close', () => drop(sub));
|
|
105
|
+
if (!timer) schedule();
|
|
106
|
+
},
|
|
107
|
+
async close() {
|
|
108
|
+
stopped = true;
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
controller?.abort();
|
|
111
|
+
for (const sub of subscribers) sub.res.end();
|
|
112
|
+
subscribers.clear();
|
|
113
|
+
await running;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/mcp/http.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { isIP } from 'node:net';
|
|
|
7
7
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
8
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import { createPinoteServer } from './core.ts';
|
|
10
|
+
import { completionEvents } from './events.ts';
|
|
10
11
|
import { pinoteToken } from '../token.ts';
|
|
11
12
|
|
|
12
13
|
export interface PinoteHttpOptions {
|
|
@@ -48,6 +49,7 @@ export async function startPinoteHttpServer(options: PinoteHttpOptions = {}) {
|
|
|
48
49
|
// Fail startup if the configured log is not writable.
|
|
49
50
|
appendFileSync(logPath, '', { mode: 0o600 });
|
|
50
51
|
const log = (event: string) => diagnostic(logPath, event);
|
|
52
|
+
const events = completionEvents(() => log('event polling failed'));
|
|
51
53
|
const digest = (value: string) => createHash('sha256').update(value).digest();
|
|
52
54
|
const expected = digest(`Bearer ${token}`);
|
|
53
55
|
type Session = { transport: StreamableHTTPServerTransport; server: ReturnType<typeof createPinoteServer>; busy: number; streams: number; touched: number };
|
|
@@ -74,8 +76,9 @@ export async function startPinoteHttpServer(options: PinoteHttpOptions = {}) {
|
|
|
74
76
|
}
|
|
75
77
|
if (!allowedHosts.has(req.headers.host ?? '')) return reject(res, 403);
|
|
76
78
|
if (req.headers.origin !== undefined && !options.allowedOrigins?.includes(req.headers.origin)) return reject(res, 403);
|
|
77
|
-
if (req.url !== '/mcp') return reject(res, 404);
|
|
78
79
|
if (stopping) return reject(res, 503);
|
|
80
|
+
if (req.url === '/events') return events.handle(req, res);
|
|
81
|
+
if (req.url !== '/mcp') return reject(res, 404);
|
|
79
82
|
if (!['GET', 'POST', 'DELETE'].includes(req.method ?? '')) { res.setHeader('Allow', 'GET, POST, DELETE'); return reject(res, 405); }
|
|
80
83
|
const id = req.headers['mcp-session-id'];
|
|
81
84
|
if (id !== undefined && (typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id))) return reject(res, 400);
|
|
@@ -162,6 +165,7 @@ export async function startPinoteHttpServer(options: PinoteHttpOptions = {}) {
|
|
|
162
165
|
const closed = new Promise<void>((ok) => listener.close(() => ok()));
|
|
163
166
|
const force = setTimeout(() => listener.closeAllConnections(), 3000);
|
|
164
167
|
try {
|
|
168
|
+
await events.close();
|
|
165
169
|
await Promise.allSettled([...all].map((session) => session.server.close()));
|
|
166
170
|
await closed;
|
|
167
171
|
} finally { clearTimeout(force); log('stopped'); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-note",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"prepack": "npm run build:mcp",
|
|
60
60
|
"mcp": "node dist-mcp/mcp/server.js",
|
|
61
61
|
"serve": "node dist-mcp/mcp/http.js",
|
|
62
|
+
"check:runtime": "node --input-type=module -e \"await import('./dist-mcp/mcp/http.js')\"",
|
|
62
63
|
"check": "tsc --noEmit",
|
|
63
64
|
"test:unit": "node --test --experimental-strip-types tests/*.test.ts",
|
|
64
65
|
"test:load": "node --test --experimental-strip-types test-load.ts test-remote-load.ts",
|
|
@@ -71,12 +72,12 @@
|
|
|
71
72
|
},
|
|
72
73
|
"devDependencies": {
|
|
73
74
|
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
74
|
-
"@earendil-works/pi-tui": "1.4.0",
|
|
75
75
|
"@types/node": "22.20.3",
|
|
76
76
|
"typebox": "1.3.7",
|
|
77
77
|
"typescript": "5.9.3"
|
|
78
78
|
},
|
|
79
79
|
"dependencies": {
|
|
80
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
80
81
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
81
82
|
"zod": "^4.6.7"
|
|
82
83
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
export interface TaskCompletedEvent {
|
|
4
|
+
taskId: number;
|
|
5
|
+
signal: AbortSignal;
|
|
6
|
+
/** Register during synchronous event dispatch, before the listener's first await. */
|
|
7
|
+
waitUntil(work: Promise<unknown>): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Local, best-effort work only; never turn an acknowledged Done into a failed write. */
|
|
11
|
+
export async function taskCompleted(
|
|
12
|
+
events: Pick<ExtensionAPI['events'], 'emit'>,
|
|
13
|
+
taskId: number,
|
|
14
|
+
lifetime: AbortSignal,
|
|
15
|
+
warn: () => void,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
if (lifetime.aborted) return;
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const work: Promise<unknown>[] = [];
|
|
20
|
+
let accepting = true, warned = false;
|
|
21
|
+
const warning = () => {
|
|
22
|
+
if (warned) return;
|
|
23
|
+
warned = true;
|
|
24
|
+
try { warn(); } catch { /* Diagnostics must not affect completion. */ }
|
|
25
|
+
};
|
|
26
|
+
let finish!: () => void;
|
|
27
|
+
const cancelled = new Promise<void>((resolve) => { finish = resolve; });
|
|
28
|
+
const cancel = () => { controller.abort(lifetime.reason); finish(); };
|
|
29
|
+
lifetime.addEventListener('abort', cancel, { once: true });
|
|
30
|
+
// One deadline covers dispatch and all listeners, not 25 seconds per listener.
|
|
31
|
+
const timer = setTimeout(() => { warning(); cancel(); }, 25_000);
|
|
32
|
+
try {
|
|
33
|
+
try {
|
|
34
|
+
events.emit('pinote:task-completed', {
|
|
35
|
+
taskId, signal: controller.signal,
|
|
36
|
+
waitUntil(promise: Promise<unknown>) {
|
|
37
|
+
const registered = accepting;
|
|
38
|
+
const observed = Promise.resolve(promise).catch(() => { if (registered && !controller.signal.aborted) warning(); });
|
|
39
|
+
if (registered) work.push(observed);
|
|
40
|
+
},
|
|
41
|
+
} satisfies TaskCompletedEvent);
|
|
42
|
+
} catch { warning(); } // Dispatch failures only; Pi itself catches listener exceptions.
|
|
43
|
+
finally { accepting = false; }
|
|
44
|
+
if (work.length) await Promise.race([Promise.all(work), cancelled]);
|
|
45
|
+
} finally {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
lifetime.removeEventListener('abort', cancel);
|
|
48
|
+
controller.abort();
|
|
49
|
+
}
|
|
50
|
+
}
|
package/remote/session.ts
CHANGED
|
@@ -32,6 +32,7 @@ export class RemoteSession {
|
|
|
32
32
|
view?: NativeView;
|
|
33
33
|
fullscreen = false;
|
|
34
34
|
onChange = () => {};
|
|
35
|
+
onCompleted?: (taskId: number, signal: AbortSignal) => Promise<void>;
|
|
35
36
|
private config: RemoteConfig;
|
|
36
37
|
private persist: (saved: SavedState) => void;
|
|
37
38
|
private factory: PeerFactory;
|
|
@@ -49,8 +50,8 @@ export class RemoteSession {
|
|
|
49
50
|
this.persist(this.saved);
|
|
50
51
|
await this.refresh(true);
|
|
51
52
|
}
|
|
52
|
-
async stop(): Promise<void> {
|
|
53
|
-
this.active = false; this.epoch++; this.lifetime.abort(); this.connection?.abort();
|
|
53
|
+
async stop(reason?: string): Promise<void> {
|
|
54
|
+
this.active = false; this.epoch++; this.lifetime.abort(reason); this.connection?.abort();
|
|
54
55
|
clearTimeout(this.refreshTimer); this.refreshTimer = undefined; this.dirty = false; this.refreshing = false;
|
|
55
56
|
const peer = this.peer; this.peer = undefined; this.view = undefined;
|
|
56
57
|
this.queue = Promise.resolve(); this.onChange();
|
|
@@ -171,8 +172,10 @@ export class RemoteSession {
|
|
|
171
172
|
} catch (error) { if (current()) this.failed(error, mutation && attempted); throw new RemoteFailure(mutation && attempted); }
|
|
172
173
|
}, signal);
|
|
173
174
|
}
|
|
174
|
-
action(view: NativeView, control: NativeControl, value?: NativeControl['value'], allow = () => true): Promise<{ draft?: string; preview?: string }> {
|
|
175
|
-
|
|
175
|
+
async action(view: NativeView, control: NativeControl, value?: NativeControl['value'], allow = () => true): Promise<{ draft?: string; preview?: string }> {
|
|
176
|
+
const signal = this.signal;
|
|
177
|
+
let completedId: number | undefined;
|
|
178
|
+
const result = await this.serial(async () => {
|
|
176
179
|
const current = this.current(); let attempted = false;
|
|
177
180
|
if (!allow()) throw new RemoteFailure(false, true);
|
|
178
181
|
const latest = this.view?.controls.find((c) => c.id === control.id && c.kind === control.kind);
|
|
@@ -180,10 +183,14 @@ export class RemoteSession {
|
|
|
180
183
|
try {
|
|
181
184
|
const peer = await this.connected(true);
|
|
182
185
|
if (!allow()) throw new RemoteFailure(false, true);
|
|
186
|
+
const taskId = this.view?.state.selectedId;
|
|
183
187
|
attempted = true;
|
|
184
188
|
const result = await peer.action({ uri: 'pinote://ui', action: control.id, revision: control.revision ?? view.revision, ...(value === undefined ? {} : { value }), _meta: this.meta() }, this.signal);
|
|
185
189
|
if (!current()) throw new RemoteFailure(true);
|
|
186
190
|
this.apply(result.view);
|
|
191
|
+
if (control.id === 'done' && control.kind === 'action' &&
|
|
192
|
+
typeof taskId === 'number' && Number.isSafeInteger(taskId) && taskId > 0 &&
|
|
193
|
+
result.view.state.selectedId == null) completedId = taskId;
|
|
187
194
|
return result;
|
|
188
195
|
} catch (error) {
|
|
189
196
|
if (!attempted && error instanceof RemoteFailure && error.stale) throw error;
|
|
@@ -191,5 +198,11 @@ export class RemoteSession {
|
|
|
191
198
|
throw new RemoteFailure(attempted);
|
|
192
199
|
}
|
|
193
200
|
});
|
|
201
|
+
// Outside the serial queue: trusted listeners may query this session themselves.
|
|
202
|
+
// Hook failures cannot invalidate or replay the acknowledged remote mutation.
|
|
203
|
+
if (completedId !== undefined && !signal.aborted) {
|
|
204
|
+
try { await this.onCompleted?.(completedId, signal); } catch { /* Best effort only. */ }
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
194
207
|
}
|
|
195
208
|
}
|
package/remote.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { registerTools } from './remote/tools.ts';
|
|
|
6
6
|
import { registerPreview, RemoteUI } from './remote/ui.ts';
|
|
7
7
|
import { registerRemoteExit } from './remote/exit.ts';
|
|
8
8
|
import { editorText } from './remote/protocol.ts';
|
|
9
|
+
import { taskCompleted } from './remote/completion.ts';
|
|
9
10
|
|
|
10
11
|
// Only an accepted /new (previousSessionFile) may carry this process-local selection.
|
|
11
12
|
// Reload/resume/fork restore branch entries, never this map or a folder-global store.
|
|
@@ -46,6 +47,9 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
|
|
|
46
47
|
try {
|
|
47
48
|
const config = remoteConfig();
|
|
48
49
|
session = new RemoteSession(config, (saved) => pi.appendEntry(ENTRY_TYPE, saved));
|
|
50
|
+
session.onCompleted = (taskId, signal) => taskCompleted(pi.events, taskId, signal, () => {
|
|
51
|
+
if (ctx.hasUI) ctx.ui.notify('Pinote: completion hook did not finish successfully. The task completion remains confirmed.', 'warning');
|
|
52
|
+
});
|
|
49
53
|
launchKey = `${config.scope}:${ctx.sessionManager.getSessionId()}`;
|
|
50
54
|
ui = new RemoteUI(pi, session, ownsLaunch);
|
|
51
55
|
ui.attach(ctx);
|
|
@@ -99,7 +103,7 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
|
|
|
99
103
|
if (transfers.size >= 32) transfers.delete(transfers.keys().next().value!);
|
|
100
104
|
transfers.set(file, saved);
|
|
101
105
|
}
|
|
102
|
-
ui?.detach(); await session?.stop();
|
|
106
|
+
ui?.detach(); await session?.stop(event.reason);
|
|
103
107
|
});
|
|
104
108
|
pi.on('before_agent_start', async (event, ctx) => {
|
|
105
109
|
let text: string;
|