pendingyou 0.14.0 → 0.15.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.
@@ -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
+ }
package/dist/args.js CHANGED
@@ -64,6 +64,7 @@ const COMMANDS = new Set([
64
64
  'permission',
65
65
  'permission-done',
66
66
  'permission-card',
67
+ 'presence',
67
68
  ]);
68
69
  const FLAGS = {
69
70
  help: {},
@@ -88,6 +89,18 @@ const FLAGS = {
88
89
  permission: { origin: true, app: true },
89
90
  'permission-done': { origin: true, app: true },
90
91
  'permission-card': { origin: true, session: true, worker: true },
92
+ presence: {
93
+ origin: true,
94
+ app: true,
95
+ state: true,
96
+ session: true,
97
+ cwd: true,
98
+ name: true,
99
+ transcript: true,
100
+ keep: false,
101
+ pid: true,
102
+ at: true,
103
+ },
91
104
  init: {
92
105
  origin: true,
93
106
  'no-login': false,
@@ -107,7 +120,20 @@ const FLAGS = {
107
120
  'mcp-headers': { origin: true, check: false, app: true },
108
121
  mcp: { origin: true, app: true, check: false },
109
122
  };
110
- const VALUE_OPTIONS = new Set(['origin', 'timeout', 'name', 'app', 'thread', 'session', 'worker']);
123
+ const VALUE_OPTIONS = new Set([
124
+ 'origin',
125
+ 'timeout',
126
+ 'name',
127
+ 'app',
128
+ 'thread',
129
+ 'session',
130
+ 'worker',
131
+ 'state',
132
+ 'cwd',
133
+ 'transcript',
134
+ 'pid',
135
+ 'at',
136
+ ]);
111
137
  function splitCommand(argv) {
112
138
  let index = 0;
113
139
  while (index < argv.length) {
@@ -128,6 +154,7 @@ export const HOOK_COMMANDS = new Set([
128
154
  'posted',
129
155
  'permission',
130
156
  'permission-done',
157
+ 'presence',
131
158
  ]);
132
159
  const THREAD = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
133
160
  function appsOf(text) {
@@ -301,6 +328,43 @@ export function parseArgs(argv, env = {}) {
301
328
  throw new UsageError('permission-card needs --session <id> and --worker <id>.');
302
329
  return { name: 'permission-card', origin, session, worker };
303
330
  }
331
+ case 'presence': {
332
+ none();
333
+ const state = text('state');
334
+ if (state !== undefined && state !== 'live' && state !== 'closed')
335
+ throw new UsageError('--state is live or closed.');
336
+ const session = text('session');
337
+ if (session !== undefined && !THREAD.test(session))
338
+ throw new UsageError('presence needs a session id, like: --session 0199a1b2-…');
339
+ const number = (key) => {
340
+ const given = text(key);
341
+ if (given === undefined)
342
+ return undefined;
343
+ if (!/^\d{1,16}$/.test(given))
344
+ throw new UsageError(`--${key} needs a number.`);
345
+ return Number(given);
346
+ };
347
+ const pid = number('pid');
348
+ const at = number('at');
349
+ const given = (key) => {
350
+ const value = text(key);
351
+ return value === undefined ? {} : { [key]: value };
352
+ };
353
+ const agentName = text('name');
354
+ return {
355
+ name: 'presence',
356
+ origin,
357
+ ...app(),
358
+ ...(state ? { state } : {}),
359
+ ...(session ? { session } : {}),
360
+ ...given('cwd'),
361
+ ...given('transcript'),
362
+ ...(agentName ? { agentName } : {}),
363
+ ...(flags.has('keep') ? { keep: true } : {}),
364
+ ...(pid ? { pid } : {}),
365
+ ...(at ? { at } : {}),
366
+ };
367
+ }
304
368
  case 'uninstall':
305
369
  none();
306
370
  return { name: 'uninstall', origin, ...apps() };
package/dist/claude.js CHANGED
@@ -8,6 +8,7 @@ import { findHook, hooksOf, mergeHooks, originArgs, removeHooks } from "./hooks.
8
8
  import { cliPackage, hookForm, hookRuns, shellQuote, } from "./install.js";
9
9
  import { atLeast, claudeVersion, isOurModDir, loadsMods, modDir, PLUGIN_DIRS, pluginDirs, pluginEnabled, separatorFor, withModDir, } from "./mod.js";
10
10
  import { removePermissionFiles } from "./permission.js";
11
+ import { removePresenceFiles } from "./presence.js";
11
12
  import { helperPath } from "./remote.js";
12
13
  import { readShim, SHIM } from "./shim.js";
13
14
  import { VERSION } from "./version.js";
@@ -17,8 +18,9 @@ export const HOOKS = {
17
18
  SessionStart: 'pickup',
18
19
  UserPromptSubmit: 'handoff',
19
20
  Stop: 'stopcheck',
21
+ SessionEnd: 'presence',
20
22
  };
21
- export const CLAUDE_HOOKS = Object.keys(HOOKS).map((event) => ({ event, sub: HOOKS[event] }));
23
+ export const CLAUDE_HOOKS = Object.keys(HOOKS).map((event) => ({ event, sub: HOOKS[event], ...(event === 'SessionEnd' ? { timeout: null } : {}) }));
22
24
  export const PERMISSION_HOOKS = [
23
25
  { event: 'PermissionRequest', sub: 'permission' },
24
26
  { event: 'PostToolUse', sub: 'permission-done' },
@@ -339,7 +341,7 @@ export async function installClaude(io, origin, options) {
339
341
  report({
340
342
  ok: true,
341
343
  text: merged.hooksChanged
342
- ? `Installed or updated the session-start pickup, next-message hand-off and Stop check hooks in ${settingsPath(io)}.`
344
+ ? `Installed or updated the session-start pickup, next-message hand-off, Stop check and session-end hooks in ${settingsPath(io)}.`
343
345
  : 'The hooks were already installed.',
344
346
  });
345
347
  const flag = originArgs(origin);
@@ -429,6 +431,7 @@ export async function uninstallClaude(io) {
429
431
  if (modRemoved)
430
432
  steps.push({ ok: true, text: `Removed the wake mod from ${PLUGIN_DIRS}.` });
431
433
  await removePermissionFiles(io);
434
+ await removePresenceFiles(io, 'claude-code');
432
435
  const skill = await removeSkill(manifest?.skill ?? null);
433
436
  if (skill)
434
437
  steps.push(skill);
@@ -493,6 +496,7 @@ export async function claudeState(io) {
493
496
  hooks: Boolean(start && ours('UserPromptSubmit')),
494
497
  stopcheck: Boolean(ours('Stop')),
495
498
  permission,
499
+ presence: Boolean(ours('SessionEnd')),
496
500
  form: form ? { ...form, runs: await hookRuns(form), shim } : null,
497
501
  mod,
498
502
  plugins,
package/dist/format.js CHANGED
@@ -1,4 +1,17 @@
1
1
  export const HANDED_PREFIX = 'Pending You: your person handed you';
2
+ export const HANDED_REPLY = 'on the question handed to you';
3
+ export const HANDED_TALK_TODO = 'If you know now, answer_delegated with your answer and how you know; if you need more, reply_in_thread to “asker”; if it’s your person’s to decide, reply_in_thread with escalate true; if not, hand_back with what you checked.';
4
+ export function isHandedWake(text) {
5
+ const first = text.trimStart().split('\n')[0] ?? '';
6
+ return (first.startsWith(HANDED_PREFIX) ||
7
+ (first.startsWith('Pending You: ') && first.includes(` ${HANDED_REPLY}, `)));
8
+ }
9
+ export function latestTalk(heard, from) {
10
+ const last = heard.messages?.at(-1);
11
+ if (!last || typeof last.body !== 'string' || !last.body.trim())
12
+ return null;
13
+ return { who: last.author === 'you' ? 'your person' : from, words: last.body };
14
+ }
2
15
  export function delegatedOf(heard) {
3
16
  const given = heard.delegated;
4
17
  if (typeof given !== 'object' || given === null)
@@ -44,6 +57,22 @@ function describeHanded(heard, delegated) {
44
57
  const id = heard.requestId;
45
58
  const from = clip(delegated.from, MAX_NAME).text;
46
59
  const note = delegated.note ? clip(delegated.note, MAX_MESSAGE) : null;
60
+ const talk = latestTalk(heard, from);
61
+ if (talk) {
62
+ let cut = false;
63
+ const wrote = heard.messages.map((message) => {
64
+ const said = quoted(message.body, MAX_MESSAGE);
65
+ cut ||= said.cut;
66
+ return `${message.author === 'you' ? 'Your person' : from}: ${said.line}`;
67
+ });
68
+ return [
69
+ `Pending You: ${talk.who === 'your person' ? 'your person wrote to you both' : `${from} replied`} ${HANDED_REPLY}, ${id} (${modeWords(delegated.mode, from)}).`,
70
+ `Question: ${clip(heard.title, MAX_TITLE).text}`,
71
+ ...wrote,
72
+ `Call get_request (requestId ${id}, name ${named(heard)}) to read what you’ve said to each other. ${HANDED_TALK_TODO}`,
73
+ ...(cut ? [`(Cut short: get_request ${id} has all of it.)`] : []),
74
+ ];
75
+ }
47
76
  return [
48
77
  `${HANDED_PREFIX} ${id}, a question from ${from} (${modeWords(delegated.mode, from)}).`,
49
78
  `Question: ${clip(heard.title, MAX_TITLE).text}`,
package/dist/handed.js CHANGED
@@ -1,4 +1,4 @@
1
- import { clip, HANDED_PREFIX, HANDED_TODO, modeWords, } from "./format.js";
1
+ import { clip, HANDED_PREFIX, HANDED_REPLY, HANDED_TALK_TODO, HANDED_TODO, latestTalk, modeWords, } from "./format.js";
2
2
  export const HANDED_FOR_MS = 12 * 60 * 60_000;
3
3
  export const HANDED_GRACE_MS = 5 * 60_000;
4
4
  const TITLE_CHARS = 80;
@@ -42,30 +42,46 @@ export function namesAnotherComputer(machine, names) {
42
42
  const ours = new Set(names.filter((name) => name.trim()).map(computerKey));
43
43
  return !ours.has(computerKey(machine));
44
44
  }
45
- export const handedCard = (heard, delegated) => ({
46
- requestId: heard.requestId,
47
- title: heard.title,
48
- from: delegated.from,
49
- mode: delegated.mode,
50
- note: Boolean(delegated.note),
51
- name: heard.sessionLabel,
52
- });
45
+ export const handedCard = (heard, delegated) => {
46
+ const said = latestTalk(heard, delegated.from);
47
+ return {
48
+ requestId: heard.requestId,
49
+ title: heard.title,
50
+ from: delegated.from,
51
+ mode: delegated.mode,
52
+ note: Boolean(delegated.note),
53
+ name: heard.sessionLabel,
54
+ ...(said ? { said } : {}),
55
+ };
56
+ };
57
+ const SAID_CHARS = 300;
58
+ const quotedWords = (words) => `“${clip(words.replace(/\s+/g, ' ').trim(), SAID_CHARS).text.replaceAll('“', '‘').replaceAll('”', '’')}”`;
59
+ const replied = (said) => said.who === 'your person'
60
+ ? 'your person wrote to you both'
61
+ : `${clip(said.who, NAME_CHARS).text} replied`;
62
+ const readTalk = (card) => `get_request (requestId ${card.requestId}, name “${card.name}”) to read what you’ve said to each other`;
53
63
  const titled = (card) => `“${clip(card.title, TITLE_CHARS).text.replaceAll('“', '‘').replaceAll('”', '’')}” (${card.requestId})`;
54
64
  const asked = (card) => {
55
65
  const from = clip(card.from, NAME_CHARS).text;
56
66
  return `from ${from} (${modeWords(card.mode, from)})`;
57
67
  };
58
68
  const read = (card) => `get_request (requestId ${card.requestId}, name “${card.name}”) to read it${card.note ? ' and their note' : ''}`;
59
- export function handedMessage(cards) {
69
+ export function handedMessage(cards, options = {}) {
60
70
  if (cards.length === 0)
61
71
  return null;
72
+ const words = options.words !== false;
73
+ const said = (card) => (card.said && words ? `: ${quotedWords(card.said.words)}` : '');
62
74
  if (cards.length === 1) {
63
75
  const card = cards[0];
76
+ if (card.said)
77
+ return `Pending You: ${replied(card.said)} ${HANDED_REPLY}, ${titled(card)}${said(card)}. Call ${readTalk(card)}. ${HANDED_TALK_TODO}`;
64
78
  return `${HANDED_PREFIX} ${titled(card)}, a question ${asked(card)}. Call ${read(card)}. ${HANDED_TODO}`;
65
79
  }
66
80
  return [
67
81
  `${HANDED_PREFIX} ${cards.length} questions from other assistants.`,
68
- ...cards.map((card) => `- ${titled(card)}, ${asked(card)}: call ${read(card)}.`),
69
- `For each one: ${HANDED_TODO.charAt(0).toLowerCase()}${HANDED_TODO.slice(1)}`,
82
+ ...cards.map((card) => card.said
83
+ ? `- ${titled(card)}, ${asked(card)}; ${replied(card.said)}${said(card)}: call ${readTalk(card)}.`
84
+ : `- ${titled(card)}, ${asked(card)}: call ${read(card)}.`),
85
+ `For each one: ${HANDED_TODO.charAt(0).toLowerCase()}${HANDED_TODO.slice(1)}${cards.some((card) => card.said) ? ` Where one replied, you may also reply_in_thread to “asker”, or escalate.` : ''}`,
70
86
  ].join('\n');
71
87
  }
package/dist/hooks.js CHANGED
@@ -2,6 +2,7 @@ import { DEFAULT_ORIGIN } from "./args.js";
2
2
  import { PlainError } from "./errors.js";
3
3
  import { shellQuote } from "./install.js";
4
4
  export const HOOK_TIMEOUT = 10;
5
+ export const lineKey = (spec) => `${spec.event}:${spec.sub}`;
5
6
  export const shWord = (text) => /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceAll("'", `'\\''`)}'`;
6
7
  export const originArgs = (origin) => origin === DEFAULT_ORIGIN ? '' : ` --origin ${shWord(origin)}`;
7
8
  export function hookLine(shim, origin, app, sub) {
@@ -36,7 +37,7 @@ export function mergeHooks(hooks, specs, lines, where) {
36
37
  const next = structuredClone(hooks);
37
38
  let changed = false;
38
39
  for (const spec of specs) {
39
- const wanted = lines[spec.event];
40
+ const wanted = lines[lineKey(spec)] ?? lines[spec.event];
40
41
  if (wanted === undefined)
41
42
  continue;
42
43
  if (next[spec.event] !== undefined && !Array.isArray(next[spec.event]))
package/dist/io.js CHANGED
@@ -201,5 +201,6 @@ export function realIo() {
201
201
  script: process.argv[1] ?? '',
202
202
  uptime: () => process.uptime() * 1000,
203
203
  background: (args) => background(process.argv[1] ?? '', args),
204
+ ppid: process.ppid,
204
205
  };
205
206
  }