pendingyou 0.34.2 → 0.35.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.1 (2026-10-11)
4
+
5
+ - Fixed: the Pending You popup in Herdr no longer breaks on a kind of card it doesn't know yet (or a grouped card with
6
+ a question of such a kind). It shows the card and says "Answer this one in Pending You: o opens it."; reply, Later,
7
+ hand over and open still work.
8
+ - The person API client inside the command line follows the SDK's 0.2.0 types: the enums in what /v1 sends are open
9
+ (any string beside the known values), since /v1 adds values over time.
10
+
11
+ ## 0.35.0 (2026-10-09)
12
+
13
+ - Claude Code hears a question handed to it within seconds. When you delegate a card to one of your Claude Code agents
14
+ (Delegate, or a card handed across), the wake mod no longer waits for its next look (every minute, or every 3
15
+ minutes after a quiet half hour): one session on the computer keeps a wait open with Pending You,
16
+ `pendingyou listen --handed --app claude-code`, which asks for handed questions alone and returns as soon as one
17
+ comes, and every session there looks at once. The session in the card's folder takes it, about 5 to 10 seconds after
18
+ you tap (the 5-second Undo hold included). The timer stays, as the fallback. Nothing to set up: it runs through the
19
+ hooks' shim `init` already wrote.
20
+ - A session that can't tell its folder now leaves a question handed to a task in one for half a minute, not 5 minutes,
21
+ and looks again as that ends.
22
+ - When sessions look together, only one tells it: each claims the question before reading it, and the last claim
23
+ stands.
24
+
3
25
  ## 0.34.2 (2026-10-09)
4
26
 
5
27
  - Fixed: in bypassPermissions or dontAsk mode, a Claude Code dialog for a tool its agent had used before (a subagent's
package/README.md CHANGED
@@ -435,14 +435,16 @@ answers, once the 5-second window to undo is over:
435
435
  session in that folder that used Pending You most recently; never the one that asked, and never two. A sign-in that
436
436
  only hears (`--oauth`) hears every computer's, so it leaves one Pending You says went to another computer (by its
437
437
  name or its hostname); one that names no computer goes by the folder.
438
- - **Claude Code**: the wake mod looks for questions handed to the session's name (`list_pending`, every minute while
439
- the session used Pending You in the last half hour, then every 3 minutes, for 12 hours), reads each with
438
+ - **Claude Code**: within seconds since 0.35.0. One session on the computer keeps a wait open with Pending You for a
439
+ handed question (`listen --handed --app claude-code`, through the hooks' shim), and when one comes every session
440
+ there looks at once; otherwise the wake mod looks for questions handed to the session's name (`list_pending`, every
441
+ minute while the session used Pending You in the last half hour, then every 3 minutes, for 12 hours). It reads each with
440
442
  `get_request`, and starts a turn: “Pending You: your person handed you “Which AWS account for staging?” (req_…), a
441
443
  question from billing-webhooks (answer freely: your answer goes straight to billing-webhooks). Call get_request
442
444
  (requestId req_…, name “infra”) to read it and their note. If you know, answer_delegated with your answer and how you
443
445
  know; if not, hand_back with what you checked.” Among sessions by one name, the one working in the task's folder
444
446
  takes it (as `list_pending` gives it, `~/…` or a full path), one in another folder never does, and one that can't
445
- tell its folder waits 5 minutes.
447
+ tell its folder waits half a minute (5 minutes before 0.35.0).
446
448
  - **Codex**: one listener for the app queues it into the thread it's for (`codex queue`, the same words).
447
449
  - **OpenCode and Pi**: the session's listener starts the turn through the plugin or extension; a session that used
448
450
  Pending You's card tools keeps listening for 12 hours, while the app is open.
@@ -330,7 +330,7 @@ export async function computerNames(io, origin, options) {
330
330
  }
331
331
  return names;
332
332
  }
333
- async function elsewhereFor(io, origin, app) {
333
+ export async function elsewhereFor(io, origin, app) {
334
334
  if (!(await hearsOnly(io, origin, app)))
335
335
  return () => false;
336
336
  const names = await computerNames(io, origin, { tailscale: true });
package/dist/args.js CHANGED
@@ -22,6 +22,7 @@ export class UsageError extends Error {
22
22
  name = 'UsageError';
23
23
  }
24
24
  const REQUEST_ID = /^req_[A-Za-z0-9-]{1,40}$/;
25
+ const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
25
26
  export function parseDuration(text) {
26
27
  const match = /^(\d+(?:\.\d+)?)(s|m|h)?$/.exec(text.trim());
27
28
  if (!match)
@@ -108,7 +109,7 @@ const FLAGS = {
108
109
  handoff: { origin: true, app: true },
109
110
  stopcheck: { origin: true, app: true },
110
111
  posted: { origin: true, app: true },
111
- listen: { origin: true, app: true, thread: true, handed: false },
112
+ listen: { origin: true, app: true, thread: true, handed: false, since: true },
112
113
  permission: { origin: true, app: true },
113
114
  'permission-done': { origin: true, app: true },
114
115
  notify: { origin: true, app: true },
@@ -360,11 +361,16 @@ export function parseArgs(argv, env = {}) {
360
361
  case 'listen': {
361
362
  none();
362
363
  const thread = text('thread');
364
+ const since = text('since');
363
365
  if (flags.has('handed')) {
364
366
  if (thread !== undefined)
365
367
  throw new UsageError('Use --thread or --handed, not both.');
366
- return { name: 'listen', origin, ...app(), handed: true };
368
+ if (since !== undefined && !ISO_TIME.test(since))
369
+ throw new UsageError('--since needs a time, like: --since 2026-10-09T12:00:00.000Z');
370
+ return { name: 'listen', origin, ...app(), handed: true, ...(since ? { since } : {}) };
367
371
  }
372
+ if (since !== undefined)
373
+ throw new UsageError('--since goes with --handed.');
368
374
  if (thread === undefined || !THREAD.test(thread))
369
375
  throw new UsageError('listen needs the thread it wakes, like: --thread 0199a1b2-…');
370
376
  return { name: 'listen', origin, ...app(), thread };
@@ -0,0 +1,58 @@
1
+ import { getJson, SignInNeeded } from "./api.js";
2
+ import { elsewhereFor } from "./apps/codex-wake.js";
3
+ import { delegatedOf } from "./format.js";
4
+ export const WAIT_MS = 2 * 60_000;
5
+ export const POLL_SECONDS = 25;
6
+ const RETRY_MS = 5000;
7
+ const after = (at, since) => !since || Date.parse(at) > Date.parse(since);
8
+ export async function waitHanded(io, options) {
9
+ const said = await listenOnce(io, options);
10
+ io.out(`${JSON.stringify(said)}\n`);
11
+ return 0;
12
+ }
13
+ async function listenOnce(io, options) {
14
+ const { origin } = options;
15
+ let since = options.since;
16
+ const started = io.now();
17
+ const elsewhere = await elsewhereFor(io, origin, 'claude-code');
18
+ let failures = 0;
19
+ while (io.now() - started < WAIT_MS && !io.signal.aborted) {
20
+ const query = new URLSearchParams({
21
+ source: 'claude-code',
22
+ handed: '1',
23
+ limit: '20',
24
+ wait: String(POLL_SECONDS),
25
+ });
26
+ if (since)
27
+ query.set('since', since);
28
+ let requests;
29
+ try {
30
+ const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, (POLL_SECONDS + 20) * 1000, io.signal, { app: 'claude-code' });
31
+ if (status !== 200)
32
+ throw new Error(`status ${status}`);
33
+ requests = Array.isArray(body.requests) ? body.requests : [];
34
+ failures = 0;
35
+ }
36
+ catch (error) {
37
+ if (error instanceof SignInNeeded)
38
+ return { error: 'signin' };
39
+ if (io.signal.aborted)
40
+ break;
41
+ if (++failures >= 2)
42
+ return { error: 'unavailable', ...(since ? { since } : {}) };
43
+ await io.sleep(RETRY_MS, io.signal);
44
+ continue;
45
+ }
46
+ const handed = requests.filter((heard) => delegatedOf(heard) !== null &&
47
+ heard.ready === true &&
48
+ typeof heard.changedAt === 'string' &&
49
+ after(heard.changedAt, since) &&
50
+ !elsewhere(heard));
51
+ for (const heard of requests)
52
+ if (typeof heard.changedAt === 'string' && after(heard.changedAt, since))
53
+ since = heard.changedAt;
54
+ if (handed.length)
55
+ return { handed: handed.map((heard) => heard.requestId), ...(since ? { since } : {}) };
56
+ }
57
+ return { handed: [], ...(since ? { since } : {}) };
58
+ }
@@ -22,6 +22,25 @@ export const askerOf = (card) => `${card.asker.agent?.name ?? card.asker.assista
22
22
  const askerName = (card) => card.asker.agent?.name ?? card.asker.assistant.name;
23
23
  export const HIGH_STAKES = 'High stakes: answer it in Pending You, where you hold to approve. o opens it.';
24
24
  export const NOT_HERE = 'Answer this one in Pending You: o opens it.';
25
+ const KINDS = [
26
+ 'choice',
27
+ 'multi',
28
+ 'approve',
29
+ 'text',
30
+ 'review',
31
+ 'action',
32
+ 'fyi',
33
+ 'group',
34
+ ];
35
+ const QUESTION_KINDS = [
36
+ 'choice',
37
+ 'multi',
38
+ 'approve',
39
+ 'text',
40
+ ];
41
+ const answersHere = (card) => card.actions.includes('answer') &&
42
+ KINDS.includes(card.kind) &&
43
+ (card.questions ?? []).every((question) => QUESTION_KINDS.includes(question.kind));
25
44
  const NO_WORDS = 'Pending You takes no words on this card from Herdr: o opens it.';
26
45
  const NO_LATER = 'This card can’t go to Later from Herdr: o opens it.';
27
46
  const NO_HANDING = 'This card can’t be handed over from Herdr: o opens it.';
@@ -126,7 +145,7 @@ function listKey(state, key, now) {
126
145
  { kind: 'targets', card },
127
146
  ];
128
147
  }
129
- if (!card.actions.includes('answer'))
148
+ if (!answersHere(card))
130
149
  return [{ ...state, note: NOT_HERE }, NONE];
131
150
  return answerKey(state, card, key);
132
151
  }
@@ -211,6 +230,8 @@ function answerKey(state, card, key) {
211
230
  return [{ ...state, note: 'y: got it.' }, NONE];
212
231
  case 'group':
213
232
  return groupKey(state, card, key, send);
233
+ default:
234
+ return [{ ...state, note: NOT_HERE }, NONE];
214
235
  }
215
236
  }
216
237
  const toggled = (chosen, id) => chosen.includes(id) ? chosen.filter((each) => each !== id) : [...chosen, id];
@@ -289,6 +310,8 @@ function groupKey(state, card, key, send) {
289
310
  const suggestion = n ? question.text?.suggestions?.[n - 1] : undefined;
290
311
  return suggestion ? answered({ text: suggestion }) : [state, NONE];
291
312
  }
313
+ default:
314
+ return [{ ...state, note: NOT_HERE }, NONE];
292
315
  }
293
316
  }
294
317
  function writeKey(state, screen, key) {
@@ -564,7 +587,7 @@ function askLines(card, state, room, now) {
564
587
  lines.push(DIM(fit(`If you don’t answer ${fromNow(card.fallback.at, now)}: ${card.fallback.text}`)));
565
588
  if (card.highStakes)
566
589
  lines.push(...wrap(HIGH_STAKES, room, 2));
567
- else if (!card.actions.includes('answer'))
590
+ else if (!answersHere(card))
568
591
  lines.push(...wrap(NOT_HERE, room, 2));
569
592
  return lines;
570
593
  }
@@ -603,6 +626,8 @@ function answerKeys(card, state) {
603
626
  : [`1–${count(question?.options)}`];
604
627
  return [...own, 'Tab next question', 'Enter send all'];
605
628
  }
629
+ default:
630
+ return [];
606
631
  }
607
632
  }
608
633
  export function keysLine(state, now) {
@@ -632,7 +657,7 @@ export function keysLine(state, now) {
632
657
  if (card) {
633
658
  const busy = state.sent?.cardId === card.id && now < state.sent.until;
634
659
  if (!card.highStakes && !busy) {
635
- if (card.actions.includes('answer'))
660
+ if (answersHere(card))
636
661
  keys.push(...answerKeys(card, state));
637
662
  if (card.actions.includes('reply'))
638
663
  keys.push('r reply');
package/dist/main.js CHANGED
@@ -9,6 +9,7 @@ import { channel } from "./channel.js";
9
9
  import { claudeWithChannel } from "./claude.js";
10
10
  import { ANSWER_DEADLINE_MARGIN_MS, readAnswerWait } from "./codex-answers.js";
11
11
  import { PlainError } from "./errors.js";
12
+ import { waitHanded } from "./handed-wait.js";
12
13
  import { hold } from "./hold.js";
13
14
  import { init, status, uninstall } from "./init.js";
14
15
  import { machine } from "./machine-command.js";
@@ -78,8 +79,14 @@ async function run(io, command, hook = { fallback: '' }) {
78
79
  catch { }
79
80
  return 0;
80
81
  case 'listen':
81
- if ('handed' in command)
82
+ if ('handed' in command) {
83
+ if (command.app === 'claude-code')
84
+ return waitHanded(io, {
85
+ origin: command.origin,
86
+ ...(command.since ? { since: command.since } : {}),
87
+ });
82
88
  return listenHanded(io, { origin: command.origin, app: command.app ?? DEFAULT_APP });
89
+ }
83
90
  return listen(io, { ...command, app: command.app ?? DEFAULT_APP });
84
91
  case 'init':
85
92
  return init(io, command);
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.34.2';
1
+ export const VERSION = '0.35.1';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pendingyou-wake",
3
3
  "displayName": "Pending You",
4
- "version": "2.49.0",
4
+ "version": "2.52.0",
5
5
  "description": "Wakes Claude Code when your person answers a Pending You card it posted, even while it sits idle. Installed by `npx pendingyou init`.",
6
6
  "author": {
7
7
  "name": "RecordPlane",
package/mod/README.md CHANGED
@@ -4,4 +4,4 @@ The pendingyou command line’s copy of the Pending You plugin’s wake mod, for
4
4
 
5
5
  In each session it learns the cards the session posts to Pending You, checks them through the session’s own Pending You connection, and starts a turn when your person answers, writes back or a fallback comes due, or hands the session a question from another of their assistants (CLI 0.14.0), even while it sits idle. It answers `npx pendingyou hold` itself, so nothing waits in the background, and shows “2 waiting on you” under the prompt, after the plugin’s name. With the Pending You plugin installed too, only one copy acts.
6
6
 
7
- Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.49.0; send changes there.
7
+ Generated from recordplane/pendingyou (packages/claude-plugin/src/wake) with Pending You’s guide 2.52.0; send changes there.
@@ -1,7 +1,8 @@
1
1
  // Generated from packages/claude-plugin/src/wake/register.ts in recordplane/pendingyou: edit that, then run
2
2
  // `pnpm --filter @pendingyou/claude-plugin generate`.
3
3
 
4
- import { argumentsOf, blockedNote, CLAIM_PREFIX, CLI_COPY, cardTool, checked, claimLease, cliConfigOf, connectionOf, dueCards, emptyBook, failed, folderOf, HANDED_FAST_MS, HANDED_READS, HERDR_EVERY_MS, HERDR_RUN_MS, handedDue, handedNext, handedOf, handedText, handedView, herdrArgv, herdrPayload, holdOf, holdReply, inHerdr, isFresh, isRefusal, isStale, isWaitingOnYou, KEEP_MS, learn, leaseHolder, loadedNote, loadedPath, momentOf, originOf, PRESENCE_EVERY_MS, PRESENCE_RUN_MS, presenceArgv, prompted, put, readBook, readClaim, readSetup, readSince, resultObject, SETUP_AFTER_MS, SETUP_RETRY_MS, SETUP_SERVER, SETUP_TRIES, settle, setUpOf, setupNeeded, setupPrompt, stateOf, statusText, TICK_MS, takesHanded, told, toTell, untold, wakeText, } from "./wake.js";
4
+ import { argumentsOf, blockedNote, CLAIM_PREFIX, CLI_COPY, cardTool, checked, claimedElsewhere, claimHeld, claimLease, cliConfigOf, connectionOf, dueCards, emptyBook, failed, folderOf, graceEnds, HANDED_FAST_MS, HANDED_READS, HERDR_EVERY_MS, HERDR_RUN_MS, handedDue, handedNext, handedOf, handedText, handedView, herdrArgv, herdrPayload, holdOf, holdReply, inHerdr, isFresh, isRefusal, isStale, isWaitingOnYou, KEEP_MS, LISTEN_RUN_MS, LISTENER_KEY, learn, leaseFor, leaseHolder, listenArgv, listened, loadedNote, loadedPath, mayListen, momentOf, originOf, PRESENCE_EVERY_MS, PRESENCE_RUN_MS, presenceArgv, prompted, put, readBook, readClaim, readListener, readSetup, readSignal, readSince, readWait, resultObject, SETUP_AFTER_MS, SETUP_RETRY_MS, SETUP_SERVER, SETUP_TRIES, SIGNAL_KEY, settle, setUpOf, setupNeeded, setupPrompt, stateOf, statusText, TICK_MS, takesHanded, told, toTell, untold, wakeText, } from "./wake.js";
5
+ const UNHEARD = { error: 'unavailable' };
5
6
  const RETRY_MS = 60_000;
6
7
  const live = {
7
8
  me: '',
@@ -29,6 +30,8 @@ const live = {
29
30
  herdr: null,
30
31
  herdrSaid: undefined,
31
32
  herdrTimer: null,
33
+ waiting: false,
34
+ signalSeen: 0,
32
35
  };
33
36
  const keyOf = (sessionId) => `session:${sessionId}`;
34
37
  function blockedServer() {
@@ -181,28 +184,41 @@ async function deliverHanded($) {
181
184
  }
182
185
  const cards = [];
183
186
  const claims = [];
187
+ const me = live.sessionId;
184
188
  for (const handed of listed) {
185
189
  const known = live.handedTold.get(handed.requestId);
186
190
  if (readSince(known, handed.updatedAt) || known === handed.updatedAt)
187
191
  continue;
188
- if (!takesHanded(handed, live.folders, live.home, now))
192
+ if (!takesHanded(handed, live.folders, live.home, now)) {
193
+ const again = graceEnds(handed, live.folders);
194
+ if (again !== null && again > now)
195
+ live.handedAt = Math.min(live.handedAt, again);
189
196
  continue;
197
+ }
190
198
  const key = `${CLAIM_PREFIX}${handed.requestId}`;
191
- if (readClaim(await $.store.get(key))?.moment === handed.updatedAt) {
199
+ if (claimedElsewhere(await $.store.get(key), handed.updatedAt, me)) {
192
200
  live.handedTold.set(handed.requestId, handed.updatedAt);
193
201
  continue;
194
202
  }
195
203
  const args = { requestId: handed.requestId, name };
196
204
  if (!(await allowed($, server, args)))
197
205
  continue;
206
+ await $.store.set(key, { moment: handed.updatedAt, at: now, by: me });
198
207
  let view = null;
199
208
  try {
200
209
  view = handedView(resultObject(await $.mcp.call(server, 'get_request', args)), handed, name);
201
210
  }
202
211
  catch { }
203
- if (!view)
212
+ const claim = await $.store.get(key);
213
+ if (!view) {
214
+ if (claimHeld(claim, handed.updatedAt, me))
215
+ await $.store.delete(key);
216
+ continue;
217
+ }
218
+ if (!claimHeld(claim, handed.updatedAt, me)) {
219
+ live.handedTold.set(handed.requestId, handed.updatedAt);
204
220
  continue;
205
- await $.store.set(key, { moment: handed.updatedAt, at: now });
221
+ }
206
222
  live.handedTold.set(handed.requestId, handed.updatedAt);
207
223
  claims.push(key);
208
224
  cards.push(view);
@@ -217,6 +233,55 @@ async function deliverHanded($) {
217
233
  live.handedAt = (await $.clock.now()) + RETRY_MS;
218
234
  });
219
235
  }
236
+ async function listenHanded($) {
237
+ const presence = live.presence;
238
+ if (!presence || live.waiting || !live.sessionId)
239
+ return;
240
+ const now = await $.clock.now();
241
+ const me = live.sessionId;
242
+ const record = readListener(await $.store.get(LISTENER_KEY));
243
+ if (!mayListen(record, me, live.seenAt, now))
244
+ return;
245
+ const lease = leaseFor(record, me, now);
246
+ await $.store.set(LISTENER_KEY, lease);
247
+ if (readListener(await $.store.get(LISTENER_KEY))?.by !== me)
248
+ return;
249
+ const argv = listenArgv(presence.config, presence.origin, lease.since);
250
+ let run;
251
+ try {
252
+ run = $.process.run(argv, { timeoutMs: LISTEN_RUN_MS });
253
+ }
254
+ catch {
255
+ await $.store.set(LISTENER_KEY, listened(lease, readWait(''), now));
256
+ return;
257
+ }
258
+ live.waiting = true;
259
+ run
260
+ .then((ran) => (ran.exitCode === 0 ? readWait(ran.stdout) : UNHEARD), () => UNHEARD)
261
+ .then(async (heard) => {
262
+ const at = await $.clock.now();
263
+ const current = readListener(await $.store.get(LISTENER_KEY));
264
+ if (!current || current.by === me)
265
+ await $.store.set(LISTENER_KEY, listened(lease, heard, at));
266
+ if ('handed' in heard && heard.handed.length) {
267
+ await $.store.set(SIGNAL_KEY, { at });
268
+ live.signalSeen = at;
269
+ live.handedAt = 0;
270
+ }
271
+ })
272
+ .catch(() => { })
273
+ .finally(() => {
274
+ live.waiting = false;
275
+ soon($);
276
+ });
277
+ }
278
+ async function heedSignal($) {
279
+ const at = readSignal(await $.store.get(SIGNAL_KEY));
280
+ if (at <= live.signalSeen)
281
+ return;
282
+ live.signalSeen = at;
283
+ live.handedAt = 0;
284
+ }
220
285
  async function prune($) {
221
286
  const now = await $.clock.now();
222
287
  for (const key of await $.store.keys()) {
@@ -298,6 +363,8 @@ async function tick($) {
298
363
  }
299
364
  for (const card of dueCards(live.book, now))
300
365
  await check($, card.requestId);
366
+ await listenHanded($).catch(() => { });
367
+ await heedSignal($).catch(() => { });
301
368
  if (!(await deliver($)))
302
369
  await deliverHanded($);
303
370
  showCards($);
package/mod/hooks/wake.js CHANGED
@@ -592,7 +592,7 @@ export function claimLease(value, me, now) {
592
592
  export const HANDED_FAST_MS = 60_000;
593
593
  export const HANDED_SLOW_MS = 3 * 60_000;
594
594
  export const HANDED_FOR_MS = 12 * 60 * 60_000;
595
- export const HANDED_GRACE_MS = 5 * 60_000;
595
+ export const HANDED_GRACE_MS = 30_000;
596
596
  export const CLAIM_PREFIX = 'handed:';
597
597
  export const HANDED_PREFIX = 'Pending You: your person handed you';
598
598
  export const HANDED_REPLY = 'on the question handed to you';
@@ -670,6 +670,12 @@ export function takesHanded(handed, folders, home, now) {
670
670
  return now - Date.parse(handed.updatedAt) >= HANDED_GRACE_MS;
671
671
  return inTaskFolder(folders, handed.cwd, home);
672
672
  }
673
+ export function graceEnds(handed, folders) {
674
+ if (handed.byName || !handed.cwd || folders.length > 0)
675
+ return null;
676
+ const at = Date.parse(handed.updatedAt);
677
+ return Number.isFinite(at) ? at + HANDED_GRACE_MS : null;
678
+ }
673
679
  export function folderOf(tool, input) {
674
680
  if (tool !== 'post_request')
675
681
  return undefined;
@@ -698,8 +704,86 @@ export function handedView(output, handed, name) {
698
704
  export function readClaim(value) {
699
705
  if (!isObject(value) || typeof value.moment !== 'string')
700
706
  return null;
701
- return { moment: value.moment, at: count(value.at) ?? 0 };
707
+ const by = text(value.by, 200);
708
+ return { moment: value.moment, at: count(value.at) ?? 0, ...(by ? { by } : {}) };
709
+ }
710
+ export function claimedElsewhere(value, moment, me) {
711
+ const claim = readClaim(value);
712
+ return claim?.moment === moment && claim.by !== me;
713
+ }
714
+ export function claimHeld(value, moment, me) {
715
+ const claim = readClaim(value);
716
+ return claim?.moment === moment && claim.by === me;
717
+ }
718
+ export const LISTENER_KEY = 'handed-listener';
719
+ export const SIGNAL_KEY = 'handed-signal';
720
+ export const LISTEN_RUN_MS = 4 * 60_000;
721
+ export const LISTENER_LEASE_MS = LISTEN_RUN_MS + 30_000;
722
+ export const LISTENER_KEEP_MS = 15_000;
723
+ export const LISTEN_RETRY_MS = 2 * 60_000;
724
+ export const LISTEN_QUIET_MS = 30 * 60_000;
725
+ const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
726
+ const isoOf = (value) => typeof value === 'string' && ISO_TIME.test(value) ? value : undefined;
727
+ export function readListener(value) {
728
+ if (!isObject(value))
729
+ return null;
730
+ const by = text(value.by, 200);
731
+ const until = count(value.until);
732
+ if (!by || until === undefined)
733
+ return null;
734
+ const since = isoOf(value.since);
735
+ const quietUntil = count(value.quietUntil);
736
+ return { by, until, ...(since ? { since } : {}), ...(quietUntil ? { quietUntil } : {}) };
737
+ }
738
+ export function mayListen(record, me, seenAt, now) {
739
+ if (seenAt <= 0 || now - seenAt >= HANDED_FOR_MS)
740
+ return false;
741
+ if (record?.quietUntil && now < record.quietUntil)
742
+ return false;
743
+ return !record || record.by === me || now >= record.until;
744
+ }
745
+ export const leaseFor = (record, me, now) => ({
746
+ by: me,
747
+ until: now + LISTENER_LEASE_MS,
748
+ ...(record?.since ? { since: record.since } : {}),
749
+ });
750
+ export function readWait(stdout) {
751
+ const line = stdout.trim().split('\n').at(-1) ?? '';
752
+ const parsed = parseJson(line);
753
+ if (!isObject(parsed))
754
+ return { error: 'unknown' };
755
+ const since = isoOf(parsed.since);
756
+ if (parsed.error === 'signin' || parsed.error === 'unavailable')
757
+ return { error: parsed.error, ...(since ? { since } : {}) };
758
+ if (!Array.isArray(parsed.handed))
759
+ return { error: 'unknown' };
760
+ const handed = parsed.handed.filter((id) => typeof id === 'string' && REQUEST_ID.test(id));
761
+ return { handed, ...(since ? { since } : {}) };
762
+ }
763
+ export function listened(record, heard, now) {
764
+ const since = heard.since ?? record.since;
765
+ const quiet = 'error' in heard
766
+ ? now + (heard.error === 'unavailable' ? LISTEN_RETRY_MS : LISTEN_QUIET_MS)
767
+ : undefined;
768
+ return {
769
+ by: record.by,
770
+ until: now + LISTENER_KEEP_MS,
771
+ ...(since ? { since } : {}),
772
+ ...(quiet ? { quietUntil: quiet } : {}),
773
+ };
774
+ }
775
+ export function listenArgv(config, origin, since) {
776
+ return [
777
+ `${config}/bin/pendingyou-hook`,
778
+ 'listen',
779
+ '--handed',
780
+ '--app',
781
+ 'claude-code',
782
+ ...(since ? ['--since', since] : []),
783
+ ...(origin === PRODUCTION ? [] : ['--origin', origin]),
784
+ ];
702
785
  }
786
+ export const readSignal = (value) => (isObject(value) ? (count(value.at) ?? 0) : 0);
703
787
  function cut(value, max) {
704
788
  const chars = [...value.replace(/\s+/g, ' ').trim()];
705
789
  return chars.length <= max
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pendingyou",
3
- "version": "0.34.2",
3
+ "version": "0.35.1",
4
4
  "description": "Pending You for your coding agents: one command sets up Claude Code, Codex, OpenCode and Pi to ask you through Pending You and hear your answers, even while they sit idle.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://www.pendingyou.com",