pi-note 0.33.3 → 0.34.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 CHANGED
@@ -90,20 +90,13 @@ client-local and is never sent to the service or persisted as launch state.
90
90
  Only a fresh session can consume it. The service must share the GUI's database;
91
91
  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
- With pi-note 0.33.1+, completing the launched task from the menu, footer or
94
- **Complete task and exit** runs `git pull --ff-only --no-rebase --no-autostash` in
95
- Pi's working directory before exiting, so Kitty closes its dedicated tab (or the
96
- standalone window's last tab). It never merges, rebases or stashes local changes.
97
- If Git fails, times out (30 seconds), or the session/draft changes, Pi stays open;
98
- the task remains completed and is not retried. Check Git manually, then `/quit`.
99
- With pi-note 0.33.3+, directories outside Git, repositories without remotes, and
100
- branches without a configured upstream skip the pull and exit without a warning.
101
- Broken Git configuration, detached HEAD in a remote-backed repository, and failures
102
- to pull a configured upstream still keep Pi open.
103
- `/quit` and Exit without completing do not pull.
93
+ With pi-note 0.33.4+, completing the launched task from the menu, footer or
94
+ **Complete task and exit** exits Pi directly, so Kitty closes its dedicated tab
95
+ (or the standalone window's last tab). Completion never runs Git commands;
96
+ Git configuration and network availability do not affect closing the session.
104
97
  This ownership survives `/reload`, but not `/new`, resume, fork, tree navigation
105
98
  or process restart. Completing a different task or an externally completed task
106
- does not pull or close Pi; failed/uncertain completion stays open.
99
+ does not close Pi; failed/uncertain completion stays open.
107
100
 
108
101
  ### Agent tools and context
109
102
 
@@ -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
+ }
@@ -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. No durable SSE
117
- replay, offline writes or automatic mutation retries are provided. After an
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.33.3",
3
+ "version": "0.34.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
  }
package/remote/exit.ts CHANGED
@@ -1,43 +1,10 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { CustomEditor, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
1
+ import { CustomEditor, type ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
2
  import { truncateToWidth } from '@earendil-works/pi-tui';
4
3
  import { clean } from './protocol.ts';
5
4
  import type { RemoteSession } from './session.ts';
6
5
 
7
- /** Completion is already committed: pull failures must never retry the task action. */
8
- export async function pullBeforeExit(pi: ExtensionAPI, ctx: ExtensionContext, signal: AbortSignal, current: () => boolean): Promise<boolean> {
9
- const sessionId = ctx.sessionManager.getSessionId(), branch = ctx.sessionManager.getLeafId();
10
- const draft = ctx.ui.getEditorText();
11
- const unchanged = () => current() && !signal.aborted && ctx.isIdle() &&
12
- ctx.sessionManager.getSessionId() === sessionId && ctx.sessionManager.getLeafId() === branch && ctx.ui.getEditorText() === draft;
13
- if (!unchanged()) return false;
14
- try {
15
- const git = (args: string[]) => spawnSync('git', args, {
16
- cwd: ctx.cwd, encoding: 'utf8', timeout: 3000, env: { ...process.env, LC_ALL: 'C' },
17
- });
18
- const repository = git(['rev-parse', '--show-toplevel']);
19
- if (!repository.error && repository.status === 128 && repository.stderr.startsWith('fatal: not a git repository (or any ')) return unchanged();
20
- if (repository.error || repository.status !== 0 || !unchanged()) throw new Error('Git repository check failed');
21
- const remotes = git(['remote']);
22
- if (remotes.error || remotes.status !== 0) throw new Error('Git remote check failed');
23
- if (!remotes.stdout.trim()) return unchanged();
24
- const branch = git(['symbolic-ref', '--quiet', 'HEAD']);
25
- if (branch.error || branch.status !== 0 || !branch.stdout.startsWith('refs/heads/')) throw new Error('Git branch check failed');
26
- const branchName = branch.stdout.trim().slice('refs/heads/'.length);
27
- const upstream = git(['config', '--get', `branch.${branchName}.merge`]);
28
- if (upstream.error || (upstream.status !== 0 && upstream.status !== 1)) throw new Error('Git upstream check failed');
29
- if (upstream.status === 1) return unchanged();
30
- if (!upstream.stdout.trim() || !unchanged()) throw new Error('Git upstream or session changed');
31
- ctx.ui.notify('Task completed. Running git pull --ff-only before closing Pi…', 'info');
32
- const result = await pi.exec('git', ['pull', '--ff-only', '--no-rebase', '--no-autostash'], { cwd: ctx.cwd, signal, timeout: 30_000 });
33
- if (result.code === 0 && !result.killed && unchanged()) return true;
34
- } catch { /* Completion succeeded; only the local pull failed. */ }
35
- if (current()) ctx.ui.notify('Task completed, but Pi stayed open: git pull failed or the session changed. Check Git manually, then /quit. Completion will not be retried.', 'warning');
36
- return false;
37
- }
38
-
39
6
  /** Default remote companion. /quit and nonempty-editor exit input remain Pi-owned. */
40
- export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSession | undefined, ownsLaunch = (_taskId: number) => false): void {
7
+ export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSession | undefined): void {
41
8
  let invalidate: (() => void) | undefined;
42
9
  let stop: (() => void) | undefined;
43
10
  let confirm: ((reason: 'exit' | 'new') => Promise<boolean>) | undefined;
@@ -88,9 +55,6 @@ export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSes
88
55
  await session.action(view, done, undefined, () => current() && ctx.isIdle() && ctx.sessionManager.getLeafId() === branch);
89
56
  if (!current()) return false;
90
57
  if (session.view && session.view.state.selectedId == null) {
91
- if (reason === 'exit' && typeof view.state.selectedId === 'number' && ownsLaunch(view.state.selectedId)) {
92
- return await pullBeforeExit(pi, ctx, operation.signal, current);
93
- }
94
58
  return true;
95
59
  }
96
60
  ctx.ui.notify('Pinote: Pi stayed in this session because the selection was not cleared. Refresh /pinote before retrying.', 'warning');
package/remote/ui.ts CHANGED
@@ -4,7 +4,6 @@ import { NativeFooter } from './footer.ts';
4
4
  import { clean, cleanUnbounded, editorText, footerText, literal, type NativeControl, type NativeView } from './protocol.ts';
5
5
  import { RemoteFailure } from './client.ts';
6
6
  import { RemoteSession } from './session.ts';
7
- import { pullBeforeExit } from './exit.ts';
8
7
  import { inlineHeight, remoteMenu, type MenuState } from './menu.ts';
9
8
 
10
9
  const line = (text: string) => clean(text).replace(/\n/g, ' ');
@@ -114,10 +113,8 @@ export class RemoteUI {
114
113
  if (!current()) return false;
115
114
  if (control.id === 'done' && ctx.isIdle() && typeof view.state.selectedId === 'number' &&
116
115
  this.session.view && this.session.view.state.selectedId == null && this.exitOnComplete(view.state.selectedId)) {
117
- if (await pullBeforeExit(this.pi, ctx, signal, current)) {
118
- this.closing = true;
119
- ctx.shutdown();
120
- }
116
+ this.closing = true;
117
+ ctx.shutdown();
121
118
  return true;
122
119
  }
123
120
  if (result.preview) this.pi.appendEntry('pinote-remote-preview', { title: line(view.title), text: clean(result.preview) });
package/remote.ts CHANGED
@@ -112,5 +112,5 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
112
112
  (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`),
113
113
  ].join('\n\n');
114
114
  });
115
- registerRemoteExit(pi, () => session, ownsLaunch);
115
+ registerRemoteExit(pi, () => session);
116
116
  }