pendingyou 0.14.0 → 0.16.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.
@@ -4,13 +4,15 @@ import { join } from 'node:path';
4
4
  import { removeSkill, saveSkill } from "../claude.js";
5
5
  import { readCredential } from "../credentials.js";
6
6
  import { configDir, readJson, readText, writeWhole } from "../files.js";
7
- import { isOurHook, mergeHooks, originArgs, removeHooks, shWord, } from "../hooks.js";
7
+ import { isOurHook, lineKey, mergeHooks, originArgs, removeHooks, shWord, } from "../hooks.js";
8
8
  import { atLeast } from "../mod.js";
9
+ import { removePresenceFiles } from "../presence.js";
9
10
  import { checkHelper, foundBy, headlessReason, helperCommand, helperPath, isOurHelper, } from "../remote.js";
10
11
  import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
11
12
  import { basicString, definedInline, headerOf, renameTable, tableSpans, tableValues, withTable, } from "../toml.js";
12
13
  import { findCodex } from "./codex-app.js";
13
14
  import { APP_NAMES } from "./ids.js";
15
+ import { switches } from "./switch.js";
14
16
  const NAME = APP_NAMES.codex;
15
17
  const SERVER = ['mcp_servers', 'pendingyou'];
16
18
  export const CODEX_MIN = '0.152.0';
@@ -27,11 +29,25 @@ export const CODEX_HOOKS = [
27
29
  { event: 'PostToolUse', sub: 'posted', matcher: CARD_TOOLS },
28
30
  { event: 'Stop', sub: 'stopcheck' },
29
31
  ];
32
+ export const CODEX_PERMISSION_HOOKS = [
33
+ { event: 'PermissionRequest', sub: 'permission' },
34
+ { event: 'PostToolUse', sub: 'permission-done' },
35
+ ];
36
+ export const CODEX_PRESENCE_HOOKS = [
37
+ { event: 'SessionEnd', sub: 'presence', timeout: 3 },
38
+ ];
39
+ export const ALL_CODEX_HOOKS = [
40
+ ...CODEX_HOOKS,
41
+ ...CODEX_PERMISSION_HOOKS,
42
+ ...CODEX_PRESENCE_HOOKS,
43
+ ];
30
44
  const EVENT_LABELS = {
31
45
  SessionStart: 'session_start',
32
46
  UserPromptSubmit: 'user_prompt_submit',
33
47
  PostToolUse: 'post_tool_use',
34
48
  Stop: 'stop',
49
+ PermissionRequest: 'permission_request',
50
+ SessionEnd: 'session_end',
35
51
  };
36
52
  const NO_MATCHER = new Set(['UserPromptSubmit', 'Stop']);
37
53
  const SIGN_IN_KEYS = new Set([
@@ -151,7 +167,7 @@ async function hookTrust(io) {
151
167
  const hooks = 'json' in file && file.json.hooks && typeof file.json.hooks === 'object'
152
168
  ? file.json.hooks
153
169
  : {};
154
- return CODEX_HOOKS.map((spec) => {
170
+ return ALL_CODEX_HOOKS.map((spec) => {
155
171
  const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
156
172
  for (const [g, group] of groups.entries())
157
173
  for (const [h, handler] of (group?.hooks ?? []).entries()) {
@@ -159,13 +175,14 @@ async function hookTrust(io) {
159
175
  continue;
160
176
  const state = tableValues(config, ['hooks', 'state', trustKey(file.path, spec.event, g, h)]);
161
177
  if (state.enabled === false)
162
- return { event: spec.event, trust: 'off' };
178
+ return { event: spec.event, sub: spec.sub, trust: 'off' };
163
179
  const hash = hookHash(spec.event, group.matcher, {
164
180
  command: handler.command,
165
181
  ...(typeof handler.timeout === 'number' ? { timeout: handler.timeout } : {}),
166
182
  });
167
183
  return {
168
184
  event: spec.event,
185
+ sub: spec.sub,
169
186
  trust: state.trusted_hash === hash
170
187
  ? 'trusted'
171
188
  : typeof state.trusted_hash === 'string'
@@ -173,7 +190,7 @@ async function hookTrust(io) {
173
190
  : 'untrusted',
174
191
  };
175
192
  }
176
- return { event: spec.event, trust: 'missing' };
193
+ return { event: spec.event, sub: spec.sub, trust: 'missing' };
177
194
  });
178
195
  }
179
196
  async function signedInByItself(io, program) {
@@ -191,15 +208,12 @@ async function signedInByItself(io, program) {
191
208
  return null;
192
209
  }
193
210
  }
194
- async function switches(io, ctx) {
195
- if (ctx.headless || ctx.yes)
196
- return true;
197
- if (!io.interactive)
198
- return false;
199
- io.out('Codex signs in to Pending You by itself here (codex mcp login). init can move it to this computer’s own sign-in, which its hooks use too: one sign-in for both. The connection it has now stays on your Assistants page until you remove it, and answers to cards it asked through that one won’t reach Codex by themselves.\n');
200
- const answer = await io.ask('Switch Codex to this computer’s sign-in? [Y/n] ');
201
- return !/^\s*no?\s*$/i.test(answer);
202
- }
211
+ const switchesFor = (io, ctx) => switches(io, ctx, {
212
+ name: 'Codex',
213
+ what: 'Codex signs in to Pending You by itself here (codex mcp login).',
214
+ uses: 'its hooks use',
215
+ headless: true,
216
+ });
203
217
  async function writeConfig(path, text) {
204
218
  return writeWhole(path, text).then(() => true, () => false);
205
219
  }
@@ -210,24 +224,26 @@ function keepTheirTrust(config, file, before) {
210
224
  'state',
211
225
  trustKey(file, event, group, handler),
212
226
  ];
213
- for (const spec of CODEX_HOOKS) {
214
- const groups = Array.isArray(before[spec.event]) ? before[spec.event] : [];
227
+ for (const event of [...new Set(ALL_CODEX_HOOKS.map((spec) => spec.event))]) {
228
+ const subs = ALL_CODEX_HOOKS.filter((spec) => spec.event === event).map((spec) => spec.sub);
229
+ const ours = (hook) => subs.some((sub) => isOurHook(hook?.command, sub));
230
+ const groups = Array.isArray(before[event]) ? before[event] : [];
215
231
  for (const [g, group] of groups.entries())
216
232
  for (const [h, hook] of (Array.isArray(group?.hooks) ? group.hooks : []).entries())
217
- if (isOurHook(hook?.command, spec.sub))
218
- next = withTable(next, state(spec.event, g, h), null);
233
+ if (ours(hook))
234
+ next = withTable(next, state(event, g, h), null);
219
235
  let at = 0;
220
236
  for (const [g, group] of groups.entries()) {
221
237
  if (!Array.isArray(group?.hooks)) {
222
238
  at++;
223
239
  continue;
224
240
  }
225
- const kept = group.hooks.flatMap((hook, h) => (isOurHook(hook?.command, spec.sub) ? [] : [h]));
241
+ const kept = group.hooks.flatMap((hook, h) => (ours(hook) ? [] : [h]));
226
242
  if (kept.length === 0)
227
243
  continue;
228
244
  for (const [h, was] of kept.entries())
229
245
  if (g !== at || was !== h)
230
- next = renameTable(next, state(spec.event, g, was), state(spec.event, at, h));
246
+ next = renameTable(next, state(event, g, was), state(event, at, h));
231
247
  at++;
232
248
  }
233
249
  }
@@ -315,7 +331,7 @@ export const codex = {
315
331
  else if ((await signedInByItself(io, program)) !== true) {
316
332
  action = 'write';
317
333
  }
318
- else if (await switches(io, ctx))
334
+ else if (await switchesFor(io, ctx))
319
335
  action = 'write';
320
336
  else {
321
337
  mode = 'oauth';
@@ -327,6 +343,7 @@ export const codex = {
327
343
  }
328
344
  }
329
345
  const version = detected.version;
346
+ let newHooks = null;
330
347
  return {
331
348
  app: codex,
332
349
  detected,
@@ -384,7 +401,15 @@ export const codex = {
384
401
  }
385
402
  const skill = await saveSkill(io, ictx.origin, codexSkillPath(io), before?.skill ?? null);
386
403
  report(skill.step);
387
- const lines = Object.fromEntries(CODEX_HOOKS.map((spec) => [spec.event, ictx.hookLine(spec.sub)]));
404
+ const cards = ctx.permissionCards ?? before?.permissionCards !== false;
405
+ const extra = mode === 'helper' && ictx.helper !== null;
406
+ const wanted = [
407
+ ...CODEX_HOOKS,
408
+ ...(extra && cards ? CODEX_PERMISSION_HOOKS : []),
409
+ ...(extra ? CODEX_PRESENCE_HOOKS : []),
410
+ ];
411
+ const unwanted = ALL_CODEX_HOOKS.filter((spec) => !wanted.includes(spec));
412
+ const lines = Object.fromEntries(wanted.map((spec) => [lineKey(spec), ictx.hookLine(spec.sub)]));
388
413
  const file = await readHooksFile(io);
389
414
  if ('invalid' in file)
390
415
  report({ ok: false, text: file.invalid });
@@ -393,14 +418,25 @@ export const codex = {
393
418
  const hooks = file.json.hooks && typeof file.json.hooks === 'object'
394
419
  ? file.json.hooks
395
420
  : {};
396
- const merged = mergeHooks(hooks, CODEX_HOOKS, lines, file.path);
397
- if (merged.changed)
398
- await writeWhole(file.path, `${JSON.stringify({ ...file.json, hooks: merged.hooks }, null, 2)}\n`);
421
+ const merged = mergeHooks(hooks, wanted, lines, file.path);
422
+ const removed = removeHooks(merged.hooks, unwanted);
423
+ if (merged.changed || removed.changed)
424
+ await writeWhole(file.path, `${JSON.stringify({ ...file.json, hooks: removed.hooks }, null, 2)}\n`);
425
+ const fresh = wanted.filter((spec) => !CODEX_HOOKS.includes(spec) && !(lineKey(spec) in (before?.hooks ?? {})));
426
+ if (before && merged.changed && fresh.length)
427
+ newHooks = [
428
+ ...(fresh.some((spec) => spec.sub === 'presence') ? ['session end'] : []),
429
+ ...(fresh.some((spec) => spec.sub.startsWith('permission'))
430
+ ? ['permission prompts']
431
+ : []),
432
+ ].join(' and ');
399
433
  report({
400
434
  ok: true,
401
435
  text: merged.changed
402
- ? `Installed or updated Codex’s session-start pickup, next-message hand-off, card and Stop check hooks in ${file.path}. Codex asks you to trust them once.`
403
- : 'Codex’s hooks were already installed.',
436
+ ? `Installed or updated Codex’s session-start pickup, next-message hand-off, card and Stop check hooks${extra ? `, ${cards ? 'its permission-prompt hooks (a card when Codex waits for your OK; --no-permission-cards leaves them out) ' : ''}and its session-end hook (Pending You hears when a thread is open)` : ''} in ${file.path}. Codex asks you to trust them once.`
437
+ : removed.changed
438
+ ? `Took out Codex’s permission-prompt hooks, as you asked: no card when Codex waits for your OK. To turn them on: npx pendingyou init --app codex --permission-cards${originArgs(ictx.origin)}`
439
+ : 'Codex’s hooks were already installed.',
404
440
  });
405
441
  }
406
442
  catch (error) {
@@ -426,6 +462,7 @@ export const codex = {
426
462
  hooks: lines,
427
463
  helper: mode === 'helper' ? ictx.helper : null,
428
464
  setup: { since: new Date(io.now()).toISOString() },
465
+ permissionCards: cards,
429
466
  };
430
467
  await writeWhole(manifestPath(io), `${JSON.stringify(manifest, null, 2)}\n`, {
431
468
  secret: true,
@@ -438,9 +475,19 @@ export const codex = {
438
475
  const start = detected.command
439
476
  ? `open the Codex app, start a chat in the folder you work in, and trust Pending You’s hooks: click Review hooks › Allow all (or Settings › Hooks › Trust all)`
440
477
  : `start codex here, or in the folder you work in, and trust Pending You’s hooks: ${TRUST_CLI}; ${TRUST_APP}`;
478
+ const again = newHooks
479
+ ? `Codex has new Pending You hooks (${newHooks}), which it skips until you trust them once: ${detected.command ? TRUST_APP : `${TRUST_CLI}; ${TRUST_APP}`}.`
480
+ : null;
441
481
  return {
442
- lines: [`Next: ${first}${start}.`, DONT_SAVE, 'Then say this to it:', '', FINISH_SAY],
443
- together: `- Codex: ${first}${start}, then say the line below to it. ${DONT_SAVE}`,
482
+ lines: [
483
+ `Next: ${first}${start}.`,
484
+ ...(again ? [again] : []),
485
+ DONT_SAVE,
486
+ 'Then say this to it:',
487
+ '',
488
+ FINISH_SAY,
489
+ ],
490
+ together: `- Codex: ${first}${start}, then say the line below to it.${again ? ` ${again}` : ''} ${DONT_SAVE}`,
444
491
  say: true,
445
492
  };
446
493
  },
@@ -454,7 +501,7 @@ export const codex = {
454
501
  let doc = (await readText(config).catch(() => null)) ?? null;
455
502
  if ('json' in file) {
456
503
  const hooks = file.json.hooks && typeof file.json.hooks === 'object' ? file.json.hooks : {};
457
- const removed = removeHooks(hooks, CODEX_HOOKS);
504
+ const removed = removeHooks(hooks, ALL_CODEX_HOOKS);
458
505
  if (removed.changed) {
459
506
  const rest = { ...file.json, hooks: removed.hooks };
460
507
  if (Object.keys(removed.hooks).length === 0)
@@ -484,6 +531,7 @@ export const codex = {
484
531
  steps.push(skill);
485
532
  await rm(join(configDir(io), 'codex-threads.json'), { force: true });
486
533
  await rm(join(configDir(io), 'codex-listen'), { recursive: true, force: true });
534
+ await removePresenceFiles(io, 'codex');
487
535
  await rm(manifestPath(io), { force: true });
488
536
  return steps;
489
537
  },
@@ -541,10 +589,30 @@ export const codex = {
541
589
  off: 'turned off',
542
590
  missing: 'missing',
543
591
  };
544
- const hooksOk = trust.every((hook) => hook.trust === 'trusted');
545
- const hooksText = trust.some((hook) => hook.trust === 'missing')
592
+ const core = trust.filter((hook) => CODEX_HOOKS.some((spec) => spec.sub === hook.sub));
593
+ const hooksOk = core.every((hook) => hook.trust === 'trusted');
594
+ const hooksText = core.some((hook) => hook.trust === 'missing')
546
595
  ? `not installed; run npx pendingyou init --app codex${flag}`
547
- : `${trust.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : `. Codex skips a hook you haven’t trusted: ${TRUST_CLI}; ${TRUST_APP}`}`;
596
+ : `${core.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}${hooksOk ? '' : `. Codex skips a hook you haven’t trusted: ${TRUST_CLI}; ${TRUST_APP}`}`;
597
+ const extraLine = (label, what, subs, off) => {
598
+ const hooks = trust.filter((hook) => subs.includes(hook.sub));
599
+ if (off)
600
+ return ` ${label} ${off}`;
601
+ if (hooks.some((hook) => hook.trust === 'missing'))
602
+ return ` missing ${label} ${what}: not installed; run npx pendingyou@latest init --app codex${flag}`;
603
+ const trusted = hooks.every((hook) => hook.trust === 'trusted');
604
+ return trusted
605
+ ? ` ok ${label} ${what} (${hooks.map((hook) => `${hook.event} trusted`).join(', ')})`
606
+ : ` missing ${label} ${what}, once you trust ${hooks.length === 1 ? 'its hook' : 'its hooks'} (${hooks.map((hook) => `${hook.event} ${words[hook.trust]}`).join(', ')}): ${TRUST_CLI}; ${TRUST_APP}`;
607
+ };
608
+ const ownConnection = credential?.kind === 'connection' && helper;
609
+ const notOwn = 'off: it needs Codex signed in through this computer’s own connection';
610
+ const permissionText = extraLine('Permission prompts:', 'a card when Codex waits for your OK', CODEX_PERMISSION_HOOKS.map((spec) => spec.sub), !ownConnection
611
+ ? notOwn
612
+ : manifest?.permissionCards === false
613
+ ? `off, as you chose; for a card when Codex waits for your OK, run npx pendingyou init --app codex --permission-cards${flag}`
614
+ : null);
615
+ const presenceText = extraLine('Presence:', 'tells Pending You when this session is open', CODEX_PRESENCE_HOOKS.map((spec) => spec.sub), ownConnection ? null : notOwn);
548
616
  const wakes = detected
549
617
  ? atLeast(detected.version, detected.command ? CODEX_APP_WAKE : CODEX_WAKE)
550
618
  : false;
@@ -562,6 +630,8 @@ export const codex = {
562
630
  : []),
563
631
  ` ${mark(skill !== null)} Skill: ${skill !== null ? `saved (${codexSkillPath(io)})` : `not saved; run npx pendingyou init --app codex${flag}`}`,
564
632
  ` ${mark(hooksOk)} Hooks: ${hooksText}`,
633
+ permissionText,
634
+ presenceText,
565
635
  ` ${mark(wakes)} Wake: ${wakes
566
636
  ? detected?.command
567
637
  ? 'codex queue wakes a chat the Codex app has open when you answer, while the app runs (one it let go, idle 3 hours or behind 10 newer idle chats, hears it when you open it again)'
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { createInterface } from 'node:readline';
7
7
  import { fileURLToPath } from 'node:url';
8
+ export const PRESENCE_EVERY_MS = 5 * 60_000;
8
9
  const PRODUCTION = 'https://www.pendingyou.com';
9
10
  const HOOK_MS = 5000;
10
11
  const TURN_MESSAGES = 200;
@@ -43,7 +44,7 @@ export function partId(now = Date.now()) {
43
44
  const random = [...randomBytes(14)].map((byte) => BASE62[byte % 62]).join('');
44
45
  return `prt_${time}${random}`;
45
46
  }
46
- function viaShim(config, origin) {
47
+ function viaShim(config, origin, directory) {
47
48
  const shim = join(config, 'bin', 'pendingyou-hook');
48
49
  const args = (sub) => [
49
50
  sub,
@@ -112,6 +113,29 @@ function viaShim(config, origin) {
112
113
  }
113
114
  },
114
115
  partId: () => partId(),
116
+ presence: (session, state) => {
117
+ try {
118
+ const child = spawn(shim, [
119
+ ...args('presence'),
120
+ '--state',
121
+ state,
122
+ '--session',
123
+ session,
124
+ '--cwd',
125
+ directory,
126
+ '--at',
127
+ String(Date.now()),
128
+ ], { detached: true, stdio: 'ignore' });
129
+ child.on('error', () => { });
130
+ child.unref();
131
+ }
132
+ catch { }
133
+ },
134
+ every: (ms, run) => {
135
+ const timer = setInterval(run, ms);
136
+ timer.unref?.();
137
+ return () => clearInterval(timer);
138
+ },
115
139
  wanted: (session) => {
116
140
  try {
117
141
  return wantsListener(JSON.parse(readFileSync(join(config, 'opencode-threads.json'), 'utf8')), session);
@@ -129,7 +153,7 @@ export const PendingYou = async (input) => {
129
153
  const origin = isObject(manifest) && typeof manifest.origin === 'string' ? manifest.origin : null;
130
154
  if (!origin)
131
155
  return {};
132
- return pendingYou(input, viaShim(config, origin));
156
+ return pendingYou(input, viaShim(config, origin, input.directory));
133
157
  }
134
158
  catch {
135
159
  return {};
@@ -138,6 +162,24 @@ export const PendingYou = async (input) => {
138
162
  export async function pendingYou(input, deps) {
139
163
  const { client, directory } = input;
140
164
  const sessions = new Map();
165
+ const present = new Set();
166
+ let stopBeat = null;
167
+ const live = (id) => {
168
+ if (present.has(id) || !deps.presence)
169
+ return;
170
+ present.add(id);
171
+ deps.presence(id, 'live');
172
+ stopBeat ??=
173
+ deps.every?.(PRESENCE_EVERY_MS, () => {
174
+ for (const each of present)
175
+ deps.presence?.(each, 'live');
176
+ }) ?? null;
177
+ };
178
+ const closed = (id) => {
179
+ if (!present.delete(id))
180
+ return;
181
+ deps.presence?.(id, 'closed');
182
+ };
141
183
  const known = async (id) => {
142
184
  const found = sessions.get(id);
143
185
  if (found && found.parent !== undefined)
@@ -328,6 +370,8 @@ export async function pendingYou(input, deps) {
328
370
  session.fresh = true;
329
371
  session.parent = typeof info.parentID === 'string' && info.parentID ? info.parentID : null;
330
372
  sessions.set(info.id, session);
373
+ if (!session.parent)
374
+ live(info.id);
331
375
  }
332
376
  else if (event.type === 'session.deleted' && isObject(properties.info)) {
333
377
  const id = properties.info.id;
@@ -338,6 +382,7 @@ export async function pendingYou(input, deps) {
338
382
  deleted.gone = true;
339
383
  deleted?.listener?.stop();
340
384
  sessions.delete(id);
385
+ closed(id);
341
386
  }
342
387
  else if (event.type === 'session.status' &&
343
388
  typeof properties.sessionID === 'string' &&
@@ -353,6 +398,7 @@ export async function pendingYou(input, deps) {
353
398
  const session = await known(id);
354
399
  if (session.parent)
355
400
  return;
401
+ live(id);
356
402
  if (typeof output.message.id === 'string')
357
403
  session.turn = output.message.id;
358
404
  if (output.parts.some((part) => ourMark(part) !== null))
@@ -409,6 +455,10 @@ export async function pendingYou(input, deps) {
409
455
  session.gone = true;
410
456
  session.listener?.stop();
411
457
  }
458
+ stopBeat?.();
459
+ stopBeat = null;
460
+ for (const id of [...present])
461
+ closed(id);
412
462
  },
413
463
  };
414
464
  }
@@ -8,11 +8,13 @@ import { configDir, readJson, readText, writeWhole } from "../files.js";
8
8
  import { originArgs } from "../hooks.js";
9
9
  import { cliPackage } from "../install.js";
10
10
  import { JsoncError, jsonOf, nodeAt, parse, removeAt, setAt } from "../jsonc.js";
11
+ import { removePresenceFiles } from "../presence.js";
11
12
  import { foundBy, headlessReason } from "../remote.js";
12
13
  import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
13
14
  import { VERSION } from "../version.js";
14
15
  import { threadFiles } from "./codex-wake.js";
15
16
  import { APP_NAMES } from "./ids.js";
17
+ import { switches } from "./switch.js";
16
18
  const NAME = APP_NAMES.opencode;
17
19
  const SCHEMA = 'https://opencode.ai/config.json';
18
20
  export const OPENCODE_MIN = '1.17.0';
@@ -170,15 +172,12 @@ async function sharedSkill(io) {
170
172
  return place;
171
173
  return null;
172
174
  }
173
- async function switches(io, ctx) {
174
- if (ctx.headless || ctx.yes)
175
- return true;
176
- if (!io.interactive)
177
- return false;
178
- io.out('OpenCode signs in to Pending You by itself here (opencode mcp auth). init can move it to this computer’s own sign-in, which its plugin uses too: one sign-in for both. The connection it has now stays on your Assistants page until you remove it, and answers to cards it asked through that one won’t reach OpenCode by themselves.\n');
179
- const answer = await io.ask('Switch OpenCode to this computer’s sign-in? [Y/n] ');
180
- return !/^\s*no?\s*$/i.test(answer);
181
- }
175
+ const switchesFor = (io, ctx) => switches(io, ctx, {
176
+ name: 'OpenCode',
177
+ what: 'OpenCode signs in to Pending You by itself here (opencode mcp auth).',
178
+ uses: 'its plugin uses',
179
+ headless: true,
180
+ });
182
181
  const writeConfig = (path, text) => mkdir(dirname(path), { recursive: true })
183
182
  .then(() => writeWhole(path, text))
184
183
  .then(() => true, () => false);
@@ -347,7 +346,7 @@ export const opencode = {
347
346
  else if (server?.kind === 'remote') {
348
347
  if (mode === 'oauth')
349
348
  action = 'keep';
350
- else if (await switches(io, ctx))
349
+ else if (await switchesFor(io, ctx))
351
350
  action = 'write';
352
351
  else {
353
352
  mode = 'oauth';
@@ -554,6 +553,7 @@ export const opencode = {
554
553
  steps.push(skill);
555
554
  for (const path of threadFiles(io, 'opencode'))
556
555
  await rm(path, { recursive: true, force: true });
556
+ await removePresenceFiles(io, 'opencode');
557
557
  await rm(manifestPath(io), { force: true });
558
558
  if (steps.length === 0)
559
559
  steps.push({ ok: true, text: 'Nothing of Pending You’s was in OpenCode.' });
@@ -648,6 +648,9 @@ export const opencode = {
648
648
  ...(bridgeLine ? [` ${mark(bridgeOk)} Bridge: ${bridgeLine}`] : []),
649
649
  ` ${mark(pluginOk)} Plugin: ${pluginLine}`,
650
650
  ` ${mark(own || shared !== null)} Skill: ${own ? `saved (${opencodeSkillPath(io)})` : shared ? `OpenCode reads the one at ${shared}` : `not saved; run ${init}`}`,
651
+ connected && pluginOk
652
+ ? ' ok Presence: tells Pending You when this session is open (the plugin says so every 5 minutes while OpenCode runs)'
653
+ : ` Presence: off: it needs ${connected ? 'the plugin' : 'OpenCode signed in through this computer’s own connection'}`,
651
654
  ];
652
655
  const setup = setupStatus(NAME, signIn.state === 'ok' ? setupOf(signIn.for) : null);
653
656
  if (setup)
@@ -11,6 +11,12 @@ const STOP_MS = 2000;
11
11
  export const 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;
12
12
  export const HANDED_FOR_MS = 12 * 60 * 60_000;
13
13
  export const HANDED_PREFIX = 'Pending You: your person handed you';
14
+ const HANDED_REPLY = ' on the question handed to you, ';
15
+ const isHandedWake = (text) => {
16
+ const first = text.trimStart().split('\n')[0] ?? '';
17
+ return (first.startsWith(HANDED_PREFIX) ||
18
+ (first.startsWith('Pending You: ') && first.includes(HANDED_REPLY)));
19
+ };
14
20
  const SOURCES = {
15
21
  startup: 'startup',
16
22
  reload: 'startup',
@@ -24,6 +30,7 @@ export const SETUP_LINES = [
24
30
  'Pending You: your person answered setup’s test question',
25
31
  'Pending You: this computer is connected (',
26
32
  ];
33
+ export const PRESENCE_EVERY_MS = 5 * 60_000;
27
34
  const MAX_STRING = 20_000;
28
35
  const SESSION = /^[A-Za-z0-9][A-Za-z0-9-]{0,99}$/;
29
36
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -146,6 +153,27 @@ export function pendingYou(setup, options = {}) {
146
153
  let listener = null;
147
154
  let again = false;
148
155
  const handedWakes = new Set();
156
+ let present = '';
157
+ let beat = null;
158
+ const presence = (id, state) => {
159
+ if (!id)
160
+ return;
161
+ try {
162
+ const child = spawn(setup.shim, args('presence', '--state', state, '--session', id, '--at', String(Date.now()), ...(cwd ? ['--cwd', cwd] : [])), { detached: true, stdio: 'ignore' });
163
+ child.on('error', () => { });
164
+ unref(child);
165
+ }
166
+ catch { }
167
+ };
168
+ const heartbeat = () => {
169
+ beat = later(() => {
170
+ if (closed || !present)
171
+ return;
172
+ presence(present, 'live');
173
+ heartbeat();
174
+ }, PRESENCE_EVERY_MS);
175
+ unref(beat);
176
+ };
149
177
  const hook = (command, input) => new Promise((resolve) => {
150
178
  let out = '';
151
179
  let done = false;
@@ -249,7 +277,7 @@ export function pendingYou(setup, options = {}) {
249
277
  for (let end = buffer.indexOf('\n'); end !== -1; end = buffer.indexOf('\n')) {
250
278
  const message = wakeOf(buffer.slice(0, end));
251
279
  buffer = buffer.slice(end + 1);
252
- if (message?.startsWith(HANDED_PREFIX)) {
280
+ if (message && isHandedWake(message)) {
253
281
  if (handedWakes.size >= 20)
254
282
  handedWakes.clear();
255
283
  handedWakes.add(message.trim());
@@ -281,6 +309,14 @@ export function pendingYou(setup, options = {}) {
281
309
  session = '';
282
310
  }
283
311
  cwd = ctx.cwd;
312
+ if (session && session !== present) {
313
+ if (present)
314
+ presence(present, 'closed');
315
+ present = session;
316
+ presence(session, 'live');
317
+ cancel(beat);
318
+ heartbeat();
319
+ }
284
320
  const said = await hook('pickup', {
285
321
  cwd,
286
322
  session_id: session,
@@ -356,6 +392,10 @@ export function pendingYou(setup, options = {}) {
356
392
  });
357
393
  pi.on('session_shutdown', async () => {
358
394
  closed = true;
395
+ cancel(beat);
396
+ if (present)
397
+ presence(present, 'closed');
398
+ present = '';
359
399
  const child = listener;
360
400
  listener = null;
361
401
  if (!child || child.exitCode !== null || child.signalCode !== null)
package/dist/apps/pi.js CHANGED
@@ -8,12 +8,14 @@ import { originArgs } from "../hooks.js";
8
8
  import { cliPackage } from "../install.js";
9
9
  import { editJson, isObject, readObject } from "../json.js";
10
10
  import { atLeast } from "../mod.js";
11
+ import { removePresenceFiles } from "../presence.js";
11
12
  import { foundBy, headlessReason } from "../remote.js";
12
13
  import { connectionTitle, FINISH_SAY, setupOf, setupStatus } from "../setup.js";
13
14
  import { shimPath } from "../shim.js";
14
15
  import { VERSION } from "../version.js";
15
16
  import { threadFiles } from "./codex-wake.js";
16
17
  import { APP_NAMES } from "./ids.js";
18
+ import { switches } from "./switch.js";
17
19
  const NAME = APP_NAMES.pi;
18
20
  export const PI_MIN = '0.99.0';
19
21
  export const PI_CIMD = '1.0.1';
@@ -133,15 +135,12 @@ function entryFor(bridge, origin, version, before) {
133
135
  ...kept,
134
136
  };
135
137
  }
136
- async function switches(io, ctx) {
137
- if (ctx.yes)
138
- return true;
139
- if (!io.interactive)
140
- return false;
141
- io.out('Pi signs in to Pending You by itself here (pi mcp login). init can move it to this computer’s own sign-in, which Pending You’s extension for Pi uses too: one sign-in for both. The connection it has now stays on your Assistants page until you remove it, and answers to cards it asked through that one won’t reach Pi by themselves.\n');
142
- const answer = await io.ask('Switch Pi to this computer’s sign-in? [Y/n] ');
143
- return !/^\s*no?\s*$/i.test(answer);
144
- }
138
+ const switchesFor = (io, ctx) => switches(io, ctx, {
139
+ name: 'Pi',
140
+ what: 'Pi signs in to Pending You by itself here (pi mcp login).',
141
+ uses: 'Pending You’s extension for Pi uses',
142
+ headless: false,
143
+ });
145
144
  async function extensionSource(io, copy) {
146
145
  const places = [
147
146
  ...(copy ? [join(cliPackage(copy.prefix), 'dist', 'apps', 'pi-extension.js')] : []),
@@ -289,7 +288,7 @@ export const pi = {
289
288
  else if (server?.url) {
290
289
  if (how === 'pi')
291
290
  action = server.exposure === 'direct' ? 'keep' : 'expose';
292
- else if (await switches(io, ctx))
291
+ else if (await switchesFor(io, ctx))
293
292
  action = 'write';
294
293
  else {
295
294
  how = 'pi';
@@ -502,6 +501,7 @@ export const pi = {
502
501
  steps.push(skill);
503
502
  for (const path of threadFiles(io, 'pi'))
504
503
  await rm(path, { recursive: true, force: true });
504
+ await removePresenceFiles(io, 'pi');
505
505
  await rm(manifestPath(io), { force: true });
506
506
  return steps;
507
507
  },
@@ -601,6 +601,9 @@ export const pi = {
601
601
  ...(bridgeLine ? [` ${mark(bridgeOk)} Bridge: ${bridgeLine}`] : []),
602
602
  ` ${mark(skillOk)} Skill: ${skillText}`,
603
603
  ` ${mark(extensionOk)} Extension: ${extensionText}`,
604
+ connected && extensionOk
605
+ ? ' ok Presence: tells Pending You when this session is open (the extension says so every 5 minutes while Pi runs)'
606
+ : ` Presence: off: it needs ${connected ? 'the extension' : 'Pi signed in through this computer’s own connection'}`,
604
607
  ];
605
608
  const setup = setupStatus(NAME, signIn.state === 'ok' ? setupOf(signIn.for) : null);
606
609
  if (setup)
@@ -0,0 +1,15 @@
1
+ export const movesOver = (name) => `Once ${name} shows up on this computer’s sign-in, Pending You moves the old sign-in’s agents over to it, with their cards and the questions you delegated to them, and signs the old one out.`;
2
+ export async function switches(io, ctx, options) {
3
+ const { name } = options;
4
+ if (ctx.yes)
5
+ return true;
6
+ if (options.headless && ctx.headless) {
7
+ io.out(`${options.what} There’s no browser here (${ctx.headless}), where that sign-in can’t finish, so init is switching ${name} to this computer’s own sign-in, which ${options.uses} too. ${movesOver(name)}\n`);
8
+ return true;
9
+ }
10
+ if (!io.interactive)
11
+ return false;
12
+ io.out(`${options.what} init can move ${name} to this computer’s own sign-in, which ${options.uses} too: one sign-in for both. ${movesOver(name)}\n`);
13
+ const answer = await io.ask(`Switch ${name} to this computer’s sign-in? [Y/n] `);
14
+ return !/^\s*no?\s*$/i.test(answer);
15
+ }