pendingyou 0.13.0 → 0.14.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +73 -11
  2. package/README.md +65 -20
  3. package/dist/api.js +0 -50
  4. package/dist/apps/claude-code.js +0 -38
  5. package/dist/apps/codex-app.js +0 -18
  6. package/dist/apps/codex-wake.js +290 -122
  7. package/dist/apps/codex.js +0 -68
  8. package/dist/apps/ids.js +0 -11
  9. package/dist/apps/opencode-plugin.js +39 -65
  10. package/dist/apps/opencode.js +0 -70
  11. package/dist/apps/pi-extension.js +60 -75
  12. package/dist/apps/pi.js +0 -62
  13. package/dist/apps/registry.js +0 -2
  14. package/dist/args.js +7 -40
  15. package/dist/bridge.js +0 -94
  16. package/dist/claude.js +0 -94
  17. package/dist/cli.js +0 -2
  18. package/dist/confirm.js +0 -69
  19. package/dist/credentials.js +0 -27
  20. package/dist/environment.js +0 -28
  21. package/dist/errors.js +0 -1
  22. package/dist/files.js +0 -16
  23. package/dist/format.js +30 -13
  24. package/dist/handed.js +71 -0
  25. package/dist/hold.js +0 -24
  26. package/dist/hooks.js +0 -26
  27. package/dist/index.js +0 -2
  28. package/dist/init.js +0 -32
  29. package/dist/install.js +0 -32
  30. package/dist/io.js +0 -10
  31. package/dist/json.js +0 -29
  32. package/dist/jsonc.js +0 -33
  33. package/dist/main.js +3 -26
  34. package/dist/mod.js +0 -23
  35. package/dist/oauth.js +0 -45
  36. package/dist/permission.js +0 -107
  37. package/dist/pickup.js +101 -62
  38. package/dist/qr.js +0 -28
  39. package/dist/redact.js +0 -77
  40. package/dist/remote.js +0 -90
  41. package/dist/setup.js +0 -30
  42. package/dist/shim.js +0 -23
  43. package/dist/signin.js +0 -42
  44. package/dist/state.js +19 -10
  45. package/dist/stopcheck.js +40 -84
  46. package/dist/toml.js +0 -34
  47. package/dist/version.js +1 -2
  48. package/dist/watch.js +3 -20
  49. package/mod/.claude-plugin/plugin.json +1 -1
  50. package/mod/README.md +2 -2
  51. package/mod/hooks/register.js +104 -61
  52. package/mod/hooks/wake.js +110 -108
  53. package/package.json +2 -1
  54. package/mod/tests/wake.test.ts +0 -247
@@ -1,88 +1,79 @@
1
- // Codex hearing answers (0.11.0), and OpenCode and Pi the same way (0.12.0): what an app's hooks remember about the
2
- // cards a thread (OpenCode, Pi: a session) posted, and the listener that wakes the thread when one is answered.
3
- //
4
- // - `pendingyou posted --app codex` is Codex's PostToolUse hook for Pending You's card tools (post_request,
5
- // update_request, reply_in_thread, cancel_request; Codex runs it only for a call that didn't fail). It records that
6
- // the turn put something in front of the person (with the card's words, for the Stop check: stopcheck.ts), and which
7
- // thread is waiting on which card (`session_id` is the thread's id), then makes sure that thread has a listener.
8
- // OpenCode's plugin runs it from `tool.execute.after` (apps/opencode-plugin.ts), and Pi's extension from Pi's
9
- // `tool_result` (apps/pi-extension.ts), with the same fields.
10
- // - `pendingyou listen --app codex --thread <id>` is that listener: one per thread (a lease file), detached, holding no
11
- // hook's output (Codex would wait for it otherwise). It long-polls Pending You with the app's own connection, as
12
- // `watch` does, and when one of the thread's cards is the agent's move (an answer, a message, a fallback that ran) it
13
- // runs `codex queue --thread <id> --message "Pending You: your person answered …"`, in the words the Claude Code wake
14
- // mod uses. Never the answer itself: a program's arguments are there for any other program on the computer to read,
15
- // so the agent reads the answer with get_request, as the message says. Codex starts a turn at once when the thread is
16
- // loaded and idle, after the current one when it's working, or when it's next resumed. It stops when the thread has
17
- // no card left waiting, or after 8 hours; the next session start or message starts it again while one is (pickup.ts).
18
- // - OpenCode's listener is the plugin's child, not detached, since only the plugin can start a turn
19
- // (client.session.promptAsync): it writes each wake as one JSON line on stdout, `{"wake": "<the words>",
20
- // "requests": ["req_…"]}`, and the plugin answers on its stdin, `{"delivered": true}` once the turn is started (or
21
- // false). Only then is it marked handed over. It stops when its stdin closes (OpenCode went), as well as when Codex's
22
- // would. Pi's listener is its extension's child in just the same way (`pi.sendMessage` starts the turn).
23
- //
24
- // Ids, names and titles only, in ~/.config/pendingyou/<app>-threads.json: never an answer.
25
1
  import { randomBytes } from 'node:crypto';
26
2
  import { mkdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises';
27
3
  import { join } from 'node:path';
28
4
  import { getJson, SignInNeeded, Unavailable } from "../api.js";
29
5
  import { DEFAULT_ORIGIN } from "../args.js";
30
- import { readCredential } from "../credentials.js";
6
+ import { connectionMachine, readCredential } from "../credentials.js";
31
7
  import { configDir, readJson, withLock, writeWhole } from "../files.js";
32
- import { clip, isClosed } from "../format.js";
33
- import { markHanded, momentOf, wasHanded } from "../state.js";
8
+ import { clip, delegatedOf, isClosed } from "../format.js";
9
+ import { chooseThread, goesBy, HANDED_FOR_MS, handedCard, handedMessage, namesAnotherComputer, } from "../handed.js";
10
+ import { macName, tailscaleName } from "../remote.js";
11
+ import { claimHanded, markHanded, momentOf, releaseHanded, wasHanded } from "../state.js";
34
12
  import { findCodex } from "./codex-app.js";
35
- /** How long a turn's record, and a thread untouched, are kept. */
13
+ import { APP_IDS } from "./ids.js";
36
14
  const KEEP_TURN_MS = 24 * 60 * 60_000;
37
15
  const KEEP_THREAD_MS = 7 * 24 * 60 * 60_000;
38
- /** The most of one card's words a turn keeps, to match an ask against (stopcheck.ts). */
39
16
  const CARD_WORDS = 20_000;
40
- /** How long a listener waits on one thread, at most, and how often it renews its lease. */
41
17
  export const LISTEN_MS = 8 * 60 * 60_000;
42
18
  const LONG_POLL_SECONDS = 25;
43
- /** A lease untouched for this long belongs to a listener that died. */
44
19
  const LEASE_STALE_MS = 90_000;
45
20
  const PATIENT_AFTER_MS = 30 * 60_000;
46
21
  const PATIENT_PAUSE_MS = 30_000;
47
22
  const BACKOFF_MS = [2000, 5000, 10_000, 20_000, 30_000, 60_000];
48
- /** How long OpenCode's listener waits for the plugin to say whether it started the turn. */
49
23
  const REPLY_MS = 60_000;
50
24
  const REQUEST_ID = /^req_[A-Za-z0-9-]{1,40}$/;
51
- /** A thread's id: Codex's (a UUID) or an OpenCode session's (`ses_…`). */
52
25
  export const THREAD = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
53
- /** Pending You's card tools as Codex and Pi name them: `mcp__pendingyou__post_request`. */
54
- const MCP_CARD_TOOL = /^mcp__([A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*)__(post_request|update_request|reply_in_thread|cancel_request)$/i;
55
- /**
56
- * The card calls the hook reads, on a Pending You server: Codex and Pi name a tool `mcp__pendingyou__post_request`,
57
- * OpenCode `pendingyou_post_request` (the server's name, `_`, the tool's).
58
- */
26
+ const HANDED = '_handed';
27
+ const NAMES = 5;
28
+ const MCP_CARD_TOOL = /^mcp__([A-Za-z0-9_]*pendingyou[A-Za-z0-9_]*)__(post_request|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i;
59
29
  const CARD_TOOLS = {
60
30
  codex: MCP_CARD_TOOL,
61
- opencode: /^([A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*)_(post_request|update_request|reply_in_thread|cancel_request)$/i,
31
+ opencode: /^([A-Za-z0-9_-]*pendingyou[A-Za-z0-9_-]*)_(post_request|update_request|reply_in_thread|cancel_request|answer_delegated|hand_back)$/i,
62
32
  pi: MCP_CARD_TOOL,
63
33
  };
64
- /** The card tool a call was, for an app whose hooks read them; null for any other tool. */
34
+ const HELPER_CALLS = new Set(['answer_delegated', 'hand_back']);
65
35
  export function cardTool(app, name) {
66
36
  const pattern = CARD_TOOLS[app];
67
37
  return pattern && typeof name === 'string' ? (pattern.exec(name)?.[2] ?? null) : null;
68
38
  }
69
39
  const statePath = (io, app) => join(configDir(io), `${app}-threads.json`);
70
40
  const leasePath = (io, app, thread) => join(configDir(io), `${app}-listen`, `${thread}.lease`);
71
- /** What uninstall takes away for an app: its threads' record and its listeners' leases. */
72
41
  export const threadFiles = (io, app) => [
73
42
  statePath(io, app),
74
43
  join(configDir(io), `${app}-listen`),
75
44
  ];
76
45
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
46
+ function threadOf(value) {
47
+ if (!isObject(value) || typeof value.at !== 'number' || !Number.isFinite(value.at))
48
+ return null;
49
+ const names = Array.isArray(value.names)
50
+ ? value.names.filter((name) => typeof name === 'string' && name.trim() !== '')
51
+ : [];
52
+ return {
53
+ origin: typeof value.origin === 'string' ? value.origin : DEFAULT_ORIGIN,
54
+ cards: isObject(value.cards) ? value.cards : {},
55
+ at: value.at,
56
+ ...(names.length ? { names: names.slice(-NAMES) } : {}),
57
+ ...(typeof value.cwd === 'string' && value.cwd ? { cwd: value.cwd } : {}),
58
+ };
59
+ }
77
60
  async function load(io, app) {
78
61
  const file = await readJson(statePath(io, app)).catch(() => null);
62
+ const threads = {};
63
+ if (isObject(file?.threads))
64
+ for (const [id, value] of Object.entries(file.threads)) {
65
+ const thread = threadOf(value);
66
+ if (thread)
67
+ threads[id] = thread;
68
+ }
79
69
  return {
80
70
  version: 1,
81
- threads: isObject(file?.threads) ? file.threads : {},
71
+ threads,
82
72
  turns: isObject(file?.turns) ? file.turns : {},
83
73
  };
84
74
  }
85
- /** Changes the state under its lock, dropping what's old. */
75
+ const mayBeHanded = (thread, now) => Boolean(thread.names?.length) && now - thread.at < HANDED_FOR_MS;
76
+ const listenable = (thread, now) => thread !== undefined && (Object.keys(thread.cards).length > 0 || mayBeHanded(thread, now));
86
77
  async function change(io, app, edit) {
87
78
  const path = statePath(io, app);
88
79
  return withLock(path, io, async () => {
@@ -93,13 +84,12 @@ async function change(io, app, edit) {
93
84
  if (!(now - turn.at < KEEP_TURN_MS))
94
85
  delete state.turns[id];
95
86
  for (const [id, thread] of Object.entries(state.threads))
96
- if (!(now - thread.at < KEEP_THREAD_MS) || Object.keys(thread.cards).length === 0)
87
+ if (!(now - thread.at < KEEP_THREAD_MS) || !listenable(thread, now))
97
88
  delete state.threads[id];
98
89
  await writeWhole(path, `${JSON.stringify(state, null, 2)}\n`, { secret: true });
99
90
  return state;
100
91
  });
101
92
  }
102
- /** A tool's answer as Codex hands it to PostToolUse (a CallToolResult): its JSON object, or null. */
103
93
  export function resultOf(response) {
104
94
  if (!isObject(response) || response.isError === true)
105
95
  return null;
@@ -118,7 +108,6 @@ export function resultOf(response) {
118
108
  }
119
109
  return null;
120
110
  }
121
- /** Every string a call carried (a card's title, summary, steps…), up to `max` characters: what a card says. */
122
111
  export function wordsIn(value, max = CARD_WORDS) {
123
112
  const found = [];
124
113
  let left = max;
@@ -140,16 +129,16 @@ export function wordsIn(value, max = CARD_WORDS) {
140
129
  return found.join('\n');
141
130
  }
142
131
  const text = (value, max) => typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined;
143
- /** The agent's name on a call: `name`, or post_request's `session.label`. */
144
132
  function nameOf(input) {
145
133
  const session = isObject(input.session) ? input.session : null;
146
134
  return text(input.name, 120) ?? text(session?.label, 120);
147
135
  }
148
- /**
149
- * `pendingyou posted --app <app>`: Codex's PostToolUse hook, and what OpenCode's plugin runs after a card call.
150
- * Remembers what the turn put in front of the person and which cards the thread waits on, then (Codex) makes sure the
151
- * thread has a listener; OpenCode's plugin starts its own. Prints nothing (Codex ignores it anyway); never fails.
152
- */
136
+ function withName(thread, name) {
137
+ if (!name)
138
+ return thread;
139
+ const names = (thread.names ?? []).filter((each) => !goesBy([each], name));
140
+ return { ...thread, names: [...names, name].slice(-NAMES) };
141
+ }
153
142
  export async function posted(io, options) {
154
143
  const { app } = options;
155
144
  try {
@@ -164,36 +153,38 @@ export async function posted(io, options) {
164
153
  const args = isObject(input.tool_input) ? input.tool_input : {};
165
154
  const output = resultOf(input.tool_response);
166
155
  const requestId = [output?.requestId, args.requestId].find((id) => typeof id === 'string' && REQUEST_ID.test(id));
156
+ const cwd = text(input.cwd, 1000);
167
157
  const now = io.now();
168
- const state = await change(io, app, (state) => {
158
+ await change(io, app, (state) => {
169
159
  if (turn && call !== 'cancel_request') {
170
160
  const record = state.turns[turn] ?? { at: now, cards: [] };
171
161
  record.cards = [...record.cards, wordsIn(args)].slice(-10);
172
162
  record.at = now;
173
163
  state.turns[turn] = record;
174
164
  }
175
- if (!requestId)
176
- return;
177
- const waiting = state.threads[thread] ?? { origin: options.origin, cards: {}, at: now };
178
- const reopen = isObject(args.reopen) ? args.reopen : null;
179
- if (call === 'cancel_request')
180
- delete waiting.cards[requestId];
181
- // A reply that leaves it the agent's turn (an answer to their message) isn't waiting on them.
182
- else if (call !== 'reply_in_thread' || reopen || output?.turn === 'you') {
183
- const before = waiting.cards[requestId];
184
- const name = nameOf(args) ?? before?.name;
185
- const title = text(args.title, 400) ?? text(reopen?.title, 400) ?? before?.title;
186
- waiting.cards[requestId] = {
187
- ...(name ? { name } : {}),
188
- ...(title ? { title } : {}),
189
- at: now,
190
- };
165
+ const waiting = withName(state.threads[thread] ?? { origin: options.origin, cards: {}, at: now }, nameOf(args));
166
+ if (requestId && !HELPER_CALLS.has(call)) {
167
+ const reopen = isObject(args.reopen) ? args.reopen : null;
168
+ if (call === 'cancel_request')
169
+ delete waiting.cards[requestId];
170
+ else if (call !== 'reply_in_thread' || reopen || output?.turn === 'you') {
171
+ const before = waiting.cards[requestId];
172
+ const name = nameOf(args) ?? before?.name;
173
+ const title = text(args.title, 400) ?? text(reopen?.title, 400) ?? before?.title;
174
+ waiting.cards[requestId] = {
175
+ ...(name ? { name } : {}),
176
+ ...(title ? { title } : {}),
177
+ at: now,
178
+ };
179
+ }
191
180
  }
181
+ if (cwd)
182
+ waiting.cwd = cwd;
192
183
  waiting.origin = options.origin;
193
184
  waiting.at = now;
194
185
  state.threads[thread] = waiting;
195
186
  });
196
- if (app === 'codex' && Object.keys(state.threads[thread]?.cards ?? {}).length > 0)
187
+ if (app === 'codex')
197
188
  await keepListening(io, options.origin, thread);
198
189
  return 0;
199
190
  }
@@ -201,48 +192,118 @@ export async function posted(io, options) {
201
192
  return 0;
202
193
  }
203
194
  }
204
- /** Whether this turn put something in front of the person, the words of each card, and whether it was nudged. */
195
+ export async function touchThread(io, app, thread, cwd) {
196
+ try {
197
+ if (!THREAD.test(thread) || !(await load(io, app)).threads[thread])
198
+ return;
199
+ const now = io.now();
200
+ await change(io, app, (state) => {
201
+ const known = state.threads[thread];
202
+ if (!known)
203
+ return;
204
+ known.at = now;
205
+ if (cwd)
206
+ known.cwd = cwd;
207
+ });
208
+ }
209
+ catch { }
210
+ }
211
+ export async function threadNames(io, app, thread) {
212
+ if (!thread)
213
+ return [];
214
+ return (await load(io, app).catch(() => null))?.threads[thread]?.names ?? [];
215
+ }
216
+ export async function markHandedTurn(io, turn, app) {
217
+ const now = io.now();
218
+ await change(io, app, (state) => {
219
+ state.turns[turn] = { ...(state.turns[turn] ?? { cards: [] }), at: now, delegated: true };
220
+ });
221
+ }
222
+ async function candidates(io, app) {
223
+ const now = io.now();
224
+ const found = [];
225
+ for (const [thread, record] of Object.entries((await load(io, app)).threads)) {
226
+ if (!listenable(record, now))
227
+ continue;
228
+ found.push({
229
+ thread,
230
+ names: record.names ?? [],
231
+ ...(record.cwd ? { cwd: record.cwd } : {}),
232
+ at: record.at,
233
+ live: app === 'codex' || (await listening(io, app, thread)),
234
+ });
235
+ }
236
+ return found;
237
+ }
238
+ export async function hearsOnly(io, origin, app) {
239
+ const credential = await readCredential(io, origin, app).catch(() => null);
240
+ return credential !== null && credential.kind !== 'connection';
241
+ }
242
+ export async function computerNames(io, origin, options) {
243
+ const names = [io.host];
244
+ for (const app of APP_IDS) {
245
+ const machine = await connectionMachine(io, origin, app).catch(() => null);
246
+ if (machine)
247
+ names.push(machine);
248
+ }
249
+ const mac = await macName({ run: io.run, platform: io.platform ?? 'linux' }).catch(() => null);
250
+ if (mac)
251
+ names.push(mac);
252
+ if (options.tailscale) {
253
+ const tailscale = await tailscaleName(io).catch(() => null);
254
+ if (tailscale)
255
+ names.push(tailscale);
256
+ }
257
+ return names;
258
+ }
259
+ async function elsewhereFor(io, origin, app) {
260
+ if (!(await hearsOnly(io, origin, app)))
261
+ return () => false;
262
+ const names = await computerNames(io, origin, { tailscale: true });
263
+ return (heard) => namesAnotherComputer(heard.machine, names);
264
+ }
265
+ export async function sessionFor(io, app, heard) {
266
+ return chooseThread(heard, await candidates(io, app), { home: io.home });
267
+ }
205
268
  export async function turnRecord(io, turn, app = 'codex') {
206
269
  const record = (await load(io, app)).turns[turn];
207
- return record ? { cards: record.cards, nudged: record.nudged === true } : null;
270
+ return record
271
+ ? {
272
+ cards: Array.isArray(record.cards) ? record.cards : [],
273
+ nudged: record.nudged === true,
274
+ delegated: record.delegated === true,
275
+ }
276
+ : null;
208
277
  }
209
- /** Remembers that the Stop check asked this turn to go on: at most once a turn. */
210
278
  export async function markNudged(io, turn, app = 'codex') {
211
279
  const now = io.now();
212
280
  await change(io, app, (state) => {
213
281
  state.turns[turn] = { ...(state.turns[turn] ?? { cards: [] }), at: now, nudged: true };
214
282
  });
215
283
  }
216
- /** Whether a listener holds this thread now: its lease, touched within LEASE_STALE_MS. */
217
284
  async function listening(io, app, thread) {
218
285
  return stat(leasePath(io, app, thread)).then((info) => Date.now() - info.mtimeMs < LEASE_STALE_MS, () => false);
219
286
  }
220
- /**
221
- * Makes sure a Codex thread with cards waiting on the person has a listener: starts one, detached, unless one is there
222
- * or Codex isn't signed in. Quick (three small reads) and never throws: the session-start and next-message hooks call
223
- * it too.
224
- */
225
287
  export async function keepListening(io, origin, thread) {
226
288
  try {
227
289
  if (!THREAD.test(thread))
228
290
  return;
229
- const cards = (await load(io, 'codex')).threads[thread]?.cards ?? {};
230
- if (Object.keys(cards).length === 0 || (await listening(io, 'codex', thread)))
291
+ const { threads } = await load(io, 'codex');
292
+ const now = io.now();
293
+ const own = Object.keys(threads[thread]?.cards ?? {}).length > 0;
294
+ const handed = Object.values(threads).some((each) => mayBeHanded(each, now));
295
+ if (!own && !handed)
231
296
  return;
232
297
  if (!(await readCredential(io, origin, 'codex')))
233
298
  return;
234
- io.background([
235
- 'listen',
236
- '--app',
237
- 'codex',
238
- '--thread',
239
- thread,
240
- ...(origin === DEFAULT_ORIGIN ? [] : ['--origin', origin]),
241
- ]);
299
+ const at = origin === DEFAULT_ORIGIN ? [] : ['--origin', origin];
300
+ if (own && !(await listening(io, 'codex', thread)))
301
+ io.background(['listen', '--app', 'codex', '--thread', thread, ...at]);
302
+ if (handed && !(await listening(io, 'codex', HANDED)))
303
+ io.background(['listen', '--app', 'codex', '--handed', ...at]);
242
304
  }
243
305
  catch { }
244
306
  }
245
- /** What happened to a card and what to do, in the words the Claude Code wake mod uses (wake.ts's line). */
246
307
  function line(heard, card) {
247
308
  const titled = `“${clip(card.title ?? heard.title, 80)
248
309
  .text.replaceAll('“', '‘')
@@ -271,7 +332,6 @@ function line(heard, card) {
271
332
  }
272
333
  }
273
334
  const capital = (sentence) => sentence.charAt(0).toUpperCase() + sentence.slice(1);
274
- /** The message that wakes the thread: every card that needs it, and what to do with each; null when none does. */
275
335
  export function wakeMessage(ready) {
276
336
  const lines = ready.flatMap(({ heard, card }) => {
277
337
  const said = line(heard, card);
@@ -293,10 +353,6 @@ export function wakeMessage(ready) {
293
353
  ...(reopen ? [reopen.trim()] : []),
294
354
  ].join('\n');
295
355
  }
296
- /**
297
- * Takes the thread's lease, a file holding this listener's own id: null when a live listener holds it. Two listeners
298
- * starting at once may both write it; the one whose id isn't there at its next renewal stops.
299
- */
300
356
  async function takeLease(io, app, thread) {
301
357
  const path = leasePath(io, app, thread);
302
358
  await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
@@ -312,7 +368,6 @@ async function takeLease(io, app, thread) {
312
368
  return null;
313
369
  }
314
370
  }
315
- /** Renews the lease while it's still this listener's (`id`); false once another listener has taken it. */
316
371
  async function renew(io, app, thread, id) {
317
372
  const path = leasePath(io, app, thread);
318
373
  if ((await readFile(path, 'utf8').catch(() => null)) !== id)
@@ -321,10 +376,6 @@ async function renew(io, app, thread, id) {
321
376
  await utimes(path, now, now).catch(() => { });
322
377
  return true;
323
378
  }
324
- /**
325
- * Codex: `codex queue`, which starts the turn itself: the codex on PATH, or the Codex app's own (0.13.0, codex-app.ts),
326
- * found once, when the listener first wakes the thread.
327
- */
328
379
  function byCodexQueue(io, thread) {
329
380
  let program = null;
330
381
  return async (message) => {
@@ -333,10 +384,6 @@ function byCodexQueue(io, thread) {
333
384
  return queued.code === 0;
334
385
  };
335
386
  }
336
- /**
337
- * OpenCode and Pi: the plugin (Pi: the extension) that started this listener reads the wake on stdout and starts the
338
- * turn, then says on stdin whether it did. stdin closing means the plugin (the app) is gone: `gone` stops the listener.
339
- */
340
387
  function byPlugin(io, gone) {
341
388
  const queue = [];
342
389
  let waiting = null;
@@ -385,12 +432,32 @@ function byPlugin(io, gone) {
385
432
  }
386
433
  };
387
434
  }
388
- /**
389
- * `pendingyou listen --app <app> --thread <id>`: waits on Pending You for the thread's cards, and wakes the thread
390
- * (Codex: `codex queue`; OpenCode and Pi: through the plugin or extension) when one is the agent's move. Quiet but for
391
- * the wake lines it hands a plugin; exits when the thread has none left, the app can't take the wake, the sign-in ends,
392
- * or after LISTEN_MS.
393
- */
435
+ async function handedHere(io, options, requests) {
436
+ const { origin, app, thread } = options;
437
+ const ready = requests.filter((heard) => heard.ready && delegatedOf(heard) && !options.elsewhere(heard));
438
+ if (ready.length === 0)
439
+ return [];
440
+ const sessions = await candidates(io, app);
441
+ const mine = [];
442
+ for (const heard of ready) {
443
+ const moment = momentOf(heard);
444
+ if (await wasHanded(io, origin, heard.requestId, moment))
445
+ continue;
446
+ if (chooseThread(heard, sessions, { home: io.home }) !== thread)
447
+ continue;
448
+ if (!(await claimHanded(io, origin, heard.requestId, moment)))
449
+ continue;
450
+ mine.push({
451
+ heard,
452
+ card: handedCard(heard, delegatedOf(heard)),
453
+ });
454
+ }
455
+ return mine;
456
+ }
457
+ async function giveBack(io, origin, list) {
458
+ for (const { heard } of list)
459
+ await releaseHanded(io, origin, heard.requestId, momentOf(heard)).catch(() => { });
460
+ }
394
461
  export async function listen(io, options) {
395
462
  const { origin, app, thread } = options;
396
463
  const lease = await takeLease(io, app, thread);
@@ -403,13 +470,16 @@ export async function listen(io, options) {
403
470
  io.signal.addEventListener('abort', stop, { once: true });
404
471
  const signal = controller.signal;
405
472
  const deliver = app === 'codex' ? byCodexQueue(io, thread) : byPlugin(io, stop);
473
+ const takes = app !== 'codex';
474
+ const elsewhere = takes ? await elsewhereFor(io, origin, app) : () => false;
406
475
  try {
407
476
  const started = io.now();
408
477
  let since;
409
478
  let failures = 0;
410
479
  while (io.now() - started < LISTEN_MS && !signal.aborted) {
411
- const cards = (await load(io, app)).threads[thread]?.cards ?? {};
412
- if (Object.keys(cards).length === 0)
480
+ const record = (await load(io, app)).threads[thread];
481
+ const cards = record?.cards ?? {};
482
+ if (!record || (Object.keys(cards).length === 0 && !(takes && mayBeHanded(record, io.now()))))
413
483
  return 0;
414
484
  if (!(await renew(io, app, thread, lease)))
415
485
  return 0;
@@ -431,7 +501,6 @@ export async function listen(io, options) {
431
501
  catch (error) {
432
502
  if (error instanceof SignInNeeded || signal.aborted)
433
503
  return 0;
434
- // Renewed first: no pause here outlasts the lease.
435
504
  if (!(await renew(io, app, thread, lease)))
436
505
  return 0;
437
506
  await io.sleep(BACKOFF_MS[Math.min(failures++, BACKOFF_MS.length - 1)], signal);
@@ -444,28 +513,34 @@ export async function listen(io, options) {
444
513
  const done = [];
445
514
  for (const heard of requests) {
446
515
  const card = cards[heard.requestId];
447
- if (!card || !heard.ready)
516
+ if (!card || !heard.ready || delegatedOf(heard))
448
517
  continue;
449
- // Handed over already (the person wrote in the app since): nothing to wake it for.
450
518
  if (await wasHanded(io, origin, heard.requestId, momentOf(heard)))
451
519
  done.push(heard.requestId);
452
520
  else
453
521
  ready.push({ heard, card });
454
522
  }
523
+ const gone = () => change(io, app, (state) => {
524
+ delete state.threads[thread];
525
+ });
455
526
  const message = wakeMessage(ready);
456
527
  if (message) {
457
- // The app doesn't know the thread (archived, gone), or can't be reached from here: the hooks hand answers over.
458
528
  if (!(await deliver(message, ready.map(({ heard }) => heard.requestId)))) {
459
- await change(io, app, (state) => {
460
- delete state.threads[thread];
461
- });
529
+ await gone();
462
530
  return 0;
463
531
  }
464
532
  await markHanded(io, origin, ready.map(({ heard }) => ({ requestId: heard.requestId, moment: momentOf(heard) })));
465
533
  }
534
+ const handed = takes ? await handedHere(io, { origin, app, thread, elsewhere }, requests) : [];
535
+ const asked = handedMessage(handed.map(({ card }) => card));
536
+ if (asked &&
537
+ !(await deliver(asked, handed.map(({ heard }) => heard.requestId)))) {
538
+ await giveBack(io, origin, handed);
539
+ await gone();
540
+ return 0;
541
+ }
466
542
  for (const { heard } of ready)
467
543
  done.push(heard.requestId);
468
- // Closed cards nobody needs telling about go too.
469
544
  for (const heard of requests)
470
545
  if (cards[heard.requestId] && isClosed(heard) && !done.includes(heard.requestId))
471
546
  done.push(heard.requestId);
@@ -483,8 +558,101 @@ export async function listen(io, options) {
483
558
  }
484
559
  finally {
485
560
  io.signal.removeEventListener('abort', stop);
486
- // Only its own: a listener that took over keeps the thread.
487
561
  if ((await readFile(leasePath(io, app, thread), 'utf8').catch(() => null)) === lease)
488
562
  await rm(leasePath(io, app, thread), { force: true }).catch(() => { });
489
563
  }
490
564
  }
565
+ export async function listenHanded(io, options) {
566
+ const { origin, app } = options;
567
+ if (app !== 'codex')
568
+ return 0;
569
+ const lease = await takeLease(io, app, HANDED);
570
+ if (!lease)
571
+ return 0;
572
+ const controller = new AbortController();
573
+ const stop = () => controller.abort();
574
+ if (io.signal.aborted)
575
+ stop();
576
+ io.signal.addEventListener('abort', stop, { once: true });
577
+ const signal = controller.signal;
578
+ let program = null;
579
+ const queue = async (thread, message) => {
580
+ program ??= findCodex(io).then((found) => found?.command ?? 'codex');
581
+ const queued = await io.run(await program, ['queue', '--thread', thread, '--message', message], 30_000);
582
+ return queued.code === 0;
583
+ };
584
+ const elsewhere = await elsewhereFor(io, origin, app);
585
+ try {
586
+ const started = io.now();
587
+ let since;
588
+ let failures = 0;
589
+ while (io.now() - started < LISTEN_MS && !signal.aborted) {
590
+ const now = io.now();
591
+ if (!Object.values((await load(io, app)).threads).some((each) => mayBeHanded(each, now)))
592
+ return 0;
593
+ if (!(await renew(io, app, HANDED, lease)))
594
+ return 0;
595
+ const query = new URLSearchParams({
596
+ source: app,
597
+ limit: '20',
598
+ wait: String(LONG_POLL_SECONDS),
599
+ });
600
+ if (since)
601
+ query.set('since', since);
602
+ let requests;
603
+ try {
604
+ const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, (LONG_POLL_SECONDS + 20) * 1000, signal, { app });
605
+ if (status !== 200)
606
+ throw new Unavailable(`Pending You answered ${status}.`);
607
+ requests = Array.isArray(body.requests) ? body.requests : [];
608
+ failures = 0;
609
+ }
610
+ catch (error) {
611
+ if (error instanceof SignInNeeded || signal.aborted)
612
+ return 0;
613
+ if (!(await renew(io, app, HANDED, lease)))
614
+ return 0;
615
+ await io.sleep(BACKOFF_MS[Math.min(failures++, BACKOFF_MS.length - 1)], signal);
616
+ continue;
617
+ }
618
+ for (const heard of requests)
619
+ if (!since || heard.changedAt > since)
620
+ since = heard.changedAt;
621
+ const sessions = requests.some((heard) => delegatedOf(heard)) ? await candidates(io, app) : [];
622
+ const byThread = new Map();
623
+ for (const heard of requests) {
624
+ const delegated = delegatedOf(heard);
625
+ if (!delegated || !heard.ready || elsewhere(heard))
626
+ continue;
627
+ const moment = momentOf(heard);
628
+ if (await wasHanded(io, origin, heard.requestId, moment))
629
+ continue;
630
+ const thread = chooseThread(heard, sessions, { home: io.home });
631
+ if (!thread || !(await claimHanded(io, origin, heard.requestId, moment)))
632
+ continue;
633
+ byThread.set(thread, [
634
+ ...(byThread.get(thread) ?? []),
635
+ { heard, card: handedCard(heard, delegated) },
636
+ ]);
637
+ }
638
+ for (const [thread, list] of byThread) {
639
+ const message = handedMessage(list.map(({ card }) => card));
640
+ if (message && (await queue(thread, message)))
641
+ continue;
642
+ await giveBack(io, origin, list);
643
+ await change(io, app, (state) => {
644
+ delete state.threads[thread];
645
+ });
646
+ since = undefined;
647
+ }
648
+ if (io.now() - started > PATIENT_AFTER_MS)
649
+ await io.sleep(PATIENT_PAUSE_MS, signal);
650
+ }
651
+ return 0;
652
+ }
653
+ finally {
654
+ io.signal.removeEventListener('abort', stop);
655
+ if ((await readFile(leasePath(io, app, HANDED), 'utf8').catch(() => null)) === lease)
656
+ await rm(leasePath(io, app, HANDED), { force: true }).catch(() => { });
657
+ }
658
+ }