pi-note 0.34.0 → 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 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). Completion never runs Git commands;
96
- Git configuration and network availability do not affect closing the session.
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`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.34.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",
@@ -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
- return this.serial(async () => {
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;