@astrosheep/square 0.3.10 → 0.3.12

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/codex-plugin/.codex-plugin/plugin.json +1 -1
  2. package/dist/activity.js +6 -7
  3. package/dist/artifact.js +337 -618
  4. package/dist/boundary-presentation.js +1 -1
  5. package/dist/cli/context.js +3 -3
  6. package/dist/cli/harness-command.js +1 -1
  7. package/dist/cli/maintenance-commands.js +12 -58
  8. package/dist/cli/observation-commands.js +14 -34
  9. package/dist/cli/program.js +3 -6
  10. package/dist/cli/registry.js +1 -2
  11. package/dist/cli/square-commands.js +39 -20
  12. package/dist/cmd/notify-once.js +5 -15
  13. package/dist/compact.js +4 -4
  14. package/dist/decisions.js +21 -7
  15. package/dist/delivery-health.js +56 -136
  16. package/dist/delivery.js +11 -47
  17. package/dist/file-lock.js +112 -0
  18. package/dist/harness-codex.js +35 -29
  19. package/dist/harness-links.js +0 -3
  20. package/dist/harness-pi.js +57 -0
  21. package/dist/harness.js +10 -15
  22. package/dist/help.js +16 -18
  23. package/dist/index.js +11 -5
  24. package/dist/list.js +3 -47
  25. package/dist/model.js +4 -6
  26. package/dist/notifications.js +217 -32
  27. package/dist/paseo-connection.js +135 -0
  28. package/dist/paseo-delivery.js +73 -144
  29. package/dist/paseo-state.js +1 -1
  30. package/dist/paseo-timeline.js +32 -42
  31. package/dist/presentation.js +24 -39
  32. package/dist/presented.js +10 -72
  33. package/dist/registry.js +23 -24
  34. package/dist/routes.js +153 -0
  35. package/dist/runtime.js +6 -21
  36. package/dist/square-application.js +56 -127
  37. package/dist/square-core.js +56 -9
  38. package/dist/stream.js +1 -1
  39. package/dist/wake-attempts.js +175 -0
  40. package/dist/wake-evidence.js +35 -0
  41. package/dist/wake-port.js +22 -0
  42. package/dist/wake-sink.js +45 -6
  43. package/dist/watch.js +1 -2
  44. package/guides/participant.md +7 -174
  45. package/package.json +6 -3
  46. package/skills/brainstorm/SKILL.md +28 -28
  47. package/skills/square/.claude-plugin/plugin.json +1 -1
  48. package/skills/square/SKILL.md +23 -14
  49. package/skills/square-feedback/SKILL.md +7 -7
  50. package/dist/doctor.js +0 -35
  51. package/dist/notification-failures.js +0 -54
  52. package/template.md +0 -4
  53. package/templates/architect.md +0 -4
  54. package/templates/brainstorm.md +0 -4
package/dist/harness.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import { doctorDeliveryHealth } from './delivery-health.js';
3
+ import { wakeGraceMs } from './notifications.js';
3
4
  import { doctorClaudePlugin, installClaudePlugin, uninstallClaudePlugin, } from './harness-claude.js';
4
5
  import { doctorCodexPlugin, installCodexPlugin, uninstallCodexPlugin, } from './harness-codex.js';
5
- import { doctorHarnessLinks, installHarnessLinks, opencodeExtensionLink, piExtensionLink, skillLinks, uninstallHarnessLinks, verifyOpenCodeRuntime, } from './harness-links.js';
6
+ import { doctorPiPackage, installPiPackage, uninstallPiPackage, } from './harness-pi.js';
7
+ import { doctorHarnessLinks, installHarnessLinks, opencodeExtensionLink, skillLinks, uninstallHarnessLinks, verifyOpenCodeRuntime, } from './harness-links.js';
6
8
  function result(lines, notes = []) {
7
9
  return { lines, notes };
8
10
  }
@@ -28,13 +30,6 @@ function readableSquarePath(squarePath) {
28
30
  }
29
31
  }
30
32
  const TARGETS = [
31
- {
32
- name: 'skills',
33
- capabilities: ['install', 'uninstall', 'doctor'],
34
- install: ({ homeDir, force }) => result(installHarnessLinks(skillLinks(homeDir), force)),
35
- uninstall: ({ homeDir }) => result(uninstallHarnessLinks(skillLinks(homeDir))),
36
- doctor: ({ homeDir }) => result(doctorHarnessLinks(skillLinks(homeDir))),
37
- },
38
33
  {
39
34
  name: 'claude',
40
35
  capabilities: ['install', 'uninstall', 'doctor'],
@@ -52,7 +47,7 @@ const TARGETS = [
52
47
  name: 'codex',
53
48
  capabilities: ['install', 'uninstall', 'doctor'],
54
49
  async install({ homeDir }) {
55
- const installed = await installCodexPlugin(homeDir);
50
+ const installed = await installCodexPlugin(homeDir, undefined, process.env.CODEX_HOME);
56
51
  const lines = [
57
52
  installed.configPath,
58
53
  installed.marketplaceRoot,
@@ -62,10 +57,10 @@ const TARGETS = [
62
57
  return result(lines, installed.notes);
63
58
  },
64
59
  async uninstall({ homeDir }) {
65
- const removed = await uninstallCodexPlugin(homeDir);
60
+ const removed = await uninstallCodexPlugin(homeDir, undefined, process.env.CODEX_HOME);
66
61
  return result(removed.paths, removed.notes);
67
62
  },
68
- async doctor({ homeDir }) { return doctorHost('Codex', () => doctorCodexPlugin(homeDir)); },
63
+ async doctor({ homeDir }) { return doctorHost('Codex', () => doctorCodexPlugin(homeDir, undefined, process.env.CODEX_HOME)); },
69
64
  },
70
65
  {
71
66
  name: 'opencode',
@@ -77,15 +72,15 @@ const TARGETS = [
77
72
  {
78
73
  name: 'pi',
79
74
  capabilities: ['install', 'uninstall', 'doctor'],
80
- install: ({ homeDir, force }) => result(installHarnessLinks([piExtensionLink(homeDir)], force)),
81
- uninstall: ({ homeDir }) => result(uninstallHarnessLinks([piExtensionLink(homeDir)])),
82
- doctor: ({ homeDir }) => result(doctorHarnessLinks([piExtensionLink(homeDir)])),
75
+ install: ({ homeDir }) => result(installPiPackage(homeDir)),
76
+ uninstall: ({ homeDir }) => result(uninstallPiPackage(homeDir)),
77
+ doctor: ({ homeDir }) => result(doctorPiPackage(homeDir)),
83
78
  },
84
79
  {
85
80
  name: 'delivery',
86
81
  capabilities: ['doctor'],
87
82
  doctor: ({ squarePath }) => result(readableSquarePath(squarePath)
88
- ? doctorDeliveryHealth(squarePath)
83
+ ? doctorDeliveryHealth(squarePath, wakeGraceMs())
89
84
  : ['○ delivery health skipped (no readable square path)']),
90
85
  },
91
86
  ];
package/dist/help.js CHANGED
@@ -11,14 +11,14 @@ const COMMANDS = [
11
11
  details: ['Options:', ' --depth <N> Descend through at most N directory levels (default 4; 0 scans only the current directory).'],
12
12
  },
13
13
  {
14
- names: ['join'], usage: '--as <name> join [--last N | --all]', usesSquare: true, group: 'participant',
14
+ names: ['join'], usage: '--as <name> join [--last N | --all] [--kick]', usesSquare: true, group: 'participant',
15
15
  summary: 'Step into the square and read its current context.',
16
16
  details: ['Options:', ' --last <N> Show the last N public activities (default 10).', ' --all Show the complete history.'],
17
17
  },
18
18
  {
19
- names: ['express'], usage: '--as <name> express [-f|--force] [--no-wait] [--beside <name> | --bell] [--reply <act_N>] <activity | ->', usesSquare: true, group: 'participant',
19
+ names: ['express'], usage: '--as <name> express [-f|--force] [--no-wait] [--bell] [--reply <activity-id>] <activity | ->', usesSquare: true, group: 'participant',
20
20
  summary: 'Speak, gesture, or do both.',
21
- details: ['Options:', ' -f, --force Express without first catching unread activity.', ' --no-wait If held or throttled, save a draft and return.', ' --beside <name> Speak aside to one participant.', " --bell Call every participant's attention to this activity.", ' --reply <act_N> Mark this activity as a reply to an earlier activity.'],
21
+ details: ['Reach:', ' @name Address someone in the square. They hear the body; everyone else sees you walk over.', " --bell Call every participant's attention to this activity without a mention.", '', 'Options:', ' -f, --force Express without first catching unread activity.', ' --no-wait If held or throttled, save a draft and return.', ' --reply <activity-id> Mark this activity as a reply to an earlier activity (for example act/12).'],
22
22
  },
23
23
  {
24
24
  names: ['catch'], usage: '--as <name> catch (--now | --idle <duration>) [--from <names>] [--mention [name]] [--replace]', usesSquare: true, group: 'participant',
@@ -39,10 +39,9 @@ const COMMANDS = [
39
39
  { names: ['claude-hook', 'codex-hook'], usage: '{command}', summary: 'Present pending attention at one native agent boundary.', hiddenFromIndex: true },
40
40
  {
41
41
  names: ['history'], usage: '[--as <name>] history [filters] [output]', usesSquare: true, group: 'participant',
42
- summary: 'Read or search what happened without changing what you have caught.',
43
- details: ['Filters:', ' --from <names> Match activities from participants.', ' --since <time>, --until <time> Match a time window.', ' --grep <regex> | --fixed <s> Search ids, participant names, and bodies.', ' --mention <name> Match mentions.', ' --pending Match attention waiting for --as <name>.', ' --ids <ids> | --at <id> Match stable activity ids.', ' -B, -A, -C <N> Set non-negative context around --at.', ' --after <id> Match activities after an id.', '', 'Results:', ' --limit <N> | --all Bound the newest matches (default 10).', ' --order <asc|desc> Set display order.', '', 'Output:', ' --full --json --format <fields> --count'],
42
+ summary: 'Read or search the archive without changing what you have caught.',
43
+ details: ['Filters:', ' --from <names> Match activities from participants.', ' --since <time>, --until <time> Match a time window.', ' --grep <regex> | --fixed <s> Search ids, participant names, and original bodies.', ' --mention <name> Match mentions.', ' --pending Match attention waiting for --as <name>.', ' --ids <ids> | --at <id> Match stable activity ids and show original bodies.', ' -B, -A, -C <N> Set non-negative context around --at.', ' --after <id> Match activities after an id.', '', 'Results:', ' --limit <N> | --all Bound the newest matches (default 10).', ' --order <asc|desc> Set display order.', '', 'Output:', ' --full --json --format <fields> --count'],
44
44
  },
45
- { names: ['warmup'], usage: 'warmup', usesSquare: true, group: 'host', summary: 'Print the complete embedded participant warmup.' },
46
45
  { names: ['status'], usage: '[--as <name>] status', usesSquare: true, group: 'participant', summary: 'Show who is present and what happened most recently.' },
47
46
  { names: ['participants'], usage: 'participants', usesSquare: true, group: 'host', summary: 'Show the full participant roster and current states.' },
48
47
  { names: ['hold'], usage: '--as <name> hold [reason | -]', usesSquare: true, group: 'participant', summary: 'Raise a hand and pause participant activity.' },
@@ -50,23 +49,22 @@ const COMMANDS = [
50
49
  {
51
50
  names: ['install'], usage: 'install (--all | <target>...) [-f]', group: 'maintenance',
52
51
  summary: 'Install Square support for one or more agent hosts.',
53
- details: ['Targets:', ' skills, claude, codex, opencode, pi', '', 'Options:', ' --all Install every supported target.', ' -f, --force Replace existing managed links.'],
52
+ details: ['Targets:', ' claude, codex, opencode, pi', '', 'Options:', ' --all Install every supported target.', ' -f, --force Replace existing managed links.'],
54
53
  },
55
54
  {
56
55
  names: ['uninstall'], usage: 'uninstall (--all | <target>...)', group: 'maintenance',
57
56
  summary: 'Remove Square support from one or more agent hosts.',
58
- details: ['Targets:', ' skills, claude, codex, opencode, pi', '', 'Options:', ' --all Remove every supported target.'],
57
+ details: ['Targets:', ' claude, codex, opencode, pi', '', 'Options:', ' --all Remove every supported target.'],
59
58
  },
60
59
  {
61
- names: ['harness'], usage: 'harness doctor [skills|claude|codex|opencode|pi|delivery]', usesSquare: true, group: 'maintenance', hiddenFromIndex: true,
60
+ names: ['harness'], usage: 'harness doctor [claude|codex|opencode|pi|delivery]', usesSquare: true, group: 'maintenance', hiddenFromIndex: true,
62
61
  summary: 'Diagnose installed agent-host support.',
63
- details: ['Targets:', ' skills, claude, codex, opencode, pi Diagnose one installed adapter.', ' delivery Diagnose delivery for the selected square.'],
62
+ details: ['Targets:', ' claude, codex, opencode, pi Diagnose one installed adapter.', ' delivery Diagnose delivery for the selected square.'],
64
63
  },
65
64
  { names: ['compact'], usage: 'compact [--keep N]', usesSquare: true, group: 'host', summary: 'Move older activity out of the working artifact while keeping the latest N.' },
66
65
  {
67
- names: ['doctor'], usage: 'doctor [--fix]', usesSquare: true, group: 'maintenance',
68
- summary: 'Diagnose artifact integrity.',
69
- details: ['Options:', ' --fix Repair recoverable artifact problems.'],
66
+ names: ['doctor'], usage: 'doctor', usesSquare: true, group: 'maintenance',
67
+ summary: 'Validate binary artifact integrity.',
70
68
  },
71
69
  ];
72
70
  function definitionFor(command) {
@@ -78,8 +76,8 @@ function isHelpFlag(value) {
78
76
  export function renderGlobalHelp() {
79
77
  const groups = [
80
78
  { key: 'participant', title: 'In the square:', order: ['join', 'express', 'catch', 'history', 'status', 'hold', 'resume', 'done'] },
81
- { key: 'host', title: 'Prepare and manage:', order: ['build', 'list', 'participants', 'warmup', 'compact'] },
82
- { key: 'maintenance', title: 'Setup and repair:', order: ['install', 'uninstall', 'doctor'] },
79
+ { key: 'host', title: 'Prepare and manage:', order: ['build', 'list', 'participants', 'compact'] },
80
+ { key: 'maintenance', title: 'Setup:', order: ['install', 'uninstall', 'doctor'] },
83
81
  ];
84
82
  const commandLines = groups.flatMap(({ key, title, order }) => [
85
83
  title,
@@ -90,7 +88,7 @@ export function renderGlobalHelp() {
90
88
  '',
91
89
  ]);
92
90
  return [
93
- 'Usage: square [--square-path <path>] [--as <name>] <command> [args...]',
91
+ 'Usage: square [--location <path>] [--as <name>] <command> [args...]',
94
92
  '',
95
93
  ...commandLines,
96
94
  "Run 'square <command> --help' for command options.",
@@ -104,7 +102,7 @@ export function renderSubcommandHelp(command) {
104
102
  const aliases = definition.names.filter((name) => name !== command);
105
103
  const usage = definition.usage.replace('{command}', command);
106
104
  return [
107
- `Usage: square ${definition.usesSquare ? '[--square-path <path>] ' : ''}${usage}`,
105
+ `Usage: square ${definition.usesSquare ? '[--location <path>] ' : ''}${usage}`,
108
106
  ...(aliases.length > 0 ? [`Aliases: ${aliases.join(', ')}`] : []),
109
107
  '',
110
108
  definition.summary,
@@ -121,7 +119,7 @@ export function helpRequest(rawArgs) {
121
119
  const args = [];
122
120
  for (let index = 0; index < rawArgs.length; index++) {
123
121
  const arg = rawArgs[index];
124
- if (arg === '--square-path' || arg === '--as') {
122
+ if (arg === '--location' || arg === '--as') {
125
123
  const value = rawArgs[index + 1];
126
124
  if (value === undefined || value.startsWith('--'))
127
125
  return undefined;
package/dist/index.js CHANGED
@@ -1,19 +1,21 @@
1
1
  export { SquareError } from './model.js';
2
+ export { extractMentions, formatActivityId, parseActivityId } from './square-core.js';
2
3
  export { loadSquare } from './artifact.js';
3
- export { extractMentions, countSays, joinedNames, doneNames, isCurrentlyJoined, publicActs, readCursor, } from './runtime.js';
4
+ export { countSays, joinedNames, doneNames, isCurrentlyJoined, publicActs, readCursor, } from './runtime.js';
4
5
  import { loadSquare } from './artifact.js';
5
- import { hasDeliveredNotification as hasDeliveredNotificationImpl, waitForDeliveredNotification as waitForDeliveredNotificationImpl, } from './notifications.js';
6
+ import { dispatchActNotifications, hasDeliveredNotification as hasDeliveredNotificationImpl, sweepPendingNotifications, waitForDeliveredNotification as waitForDeliveredNotificationImpl, } from './notifications.js';
6
7
  import { WATCH_STALE_MS, freshWatchLease, getReadState as getDocReadState, } from './runtime.js';
7
8
  import { resolveKnownName } from './decisions.js';
8
9
  import { execute } from './square-application.js';
10
+ import { parseActivityId } from './square-core.js';
9
11
  export { WATCH_STALE_MS };
10
12
  function actRefIndex(ref) {
11
13
  if (typeof ref === 'number')
12
14
  return ref;
13
- const match = ref.match(/^act_(\d+)$/);
14
- if (!match)
15
+ const index = parseActivityId(ref);
16
+ if (index === undefined)
15
17
  throw new Error(`Invalid act ref: ${ref}`);
16
- return Number(match[1]);
18
+ return index;
17
19
  }
18
20
  export function getReadState(squarePath, name) {
19
21
  const doc = loadSquare(squarePath);
@@ -52,6 +54,7 @@ export async function resume(squarePath, actor) {
52
54
  await execute(squarePath, { type: 'resume', actor, now: Date.now() });
53
55
  }
54
56
  export async function express(squarePath, name, body, opts = {}) {
57
+ await sweepPendingNotifications(squarePath);
55
58
  const committed = await execute(squarePath, {
56
59
  type: 'say',
57
60
  name,
@@ -60,6 +63,9 @@ export async function express(squarePath, name, body, opts = {}) {
60
63
  now: Date.now(),
61
64
  ...(opts.reply === undefined ? {} : { reply: actRefIndex(opts.reply) }),
62
65
  });
66
+ const sayAct = committed.acts.find((act) => act.kind === 'say');
67
+ if (sayAct !== undefined)
68
+ await dispatchActNotifications(squarePath, sayAct);
63
69
  if (committed.result.type !== 'sent')
64
70
  throw new Error(`Activity rejected: ${committed.result.type}`);
65
71
  }
package/dist/list.js CHANGED
@@ -1,37 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { parseSquare } from './artifact.js';
3
+ import { probeSquare } from './artifact.js';
4
4
  import { inSquareCount, publicActs } from './runtime.js';
5
5
  import { formatRelativeTime } from './time.js';
6
6
  const DEFAULT_LIST_DEPTH = 4;
7
7
  const LIST_SKIP_DIRS = new Set(['.git', 'node_modules', 'dist']);
8
- function frontmatterOf(text) {
9
- const match = text.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
10
- return match ? match[1] : null;
11
- }
12
- function candidateFrontmatter(filePath) {
13
- let fd;
14
- try {
15
- fd = fs.openSync(filePath, 'r');
16
- const buffer = Buffer.allocUnsafe(4096);
17
- const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
18
- const prefix = buffer.toString('utf8', 0, bytesRead);
19
- if (!prefix.startsWith('---\n'))
20
- return null;
21
- const frontmatter = frontmatterOf(prefix);
22
- return frontmatter ?? frontmatterOf(fs.readFileSync(filePath, 'utf8'));
23
- }
24
- catch {
25
- return null;
26
- }
27
- finally {
28
- if (fd !== undefined)
29
- fs.closeSync(fd);
30
- }
31
- }
32
8
  function readSquareListItem(filePath, root) {
33
- let text;
34
- let doc;
35
9
  let stat;
36
10
  try {
37
11
  stat = fs.statSync(filePath);
@@ -39,27 +13,9 @@ function readSquareListItem(filePath, root) {
39
13
  catch {
40
14
  return null;
41
15
  }
42
- const frontmatter = candidateFrontmatter(filePath);
43
- if (!frontmatter)
44
- return null;
45
- if (!/^hard_cap:\s*(-1|\d+)\s*$/m.test(frontmatter))
16
+ const doc = probeSquare(filePath);
17
+ if (doc === undefined)
46
18
  return null;
47
- if (!/^format_version:\s*3\s*$/m.test(frontmatter))
48
- return null;
49
- try {
50
- text = fs.readFileSync(filePath, 'utf8');
51
- }
52
- catch {
53
- return null;
54
- }
55
- if (!text.includes('<!-- square:warmup -->') || !text.includes('<!-- square:activities -->'))
56
- return null;
57
- try {
58
- doc = parseSquare(text);
59
- }
60
- catch {
61
- return null;
62
- }
63
19
  const relative = path.relative(root, filePath) || path.basename(filePath);
64
20
  return {
65
21
  path: relative,
package/dist/model.js CHANGED
@@ -1,4 +1,8 @@
1
1
  // Shared model and constants for Square.
2
+ export const WAKE_ROUTE_KINDS = ['opencode-server', 'codex-app-server', 'claude-native', 'pi-extension', 'paseo'];
3
+ export function isWakeRouteKind(value) {
4
+ return typeof value === 'string' && WAKE_ROUTE_KINDS.includes(value);
5
+ }
2
6
  export class SquareError extends Error {
3
7
  code;
4
8
  constructor(code, message) {
@@ -7,12 +11,6 @@ export class SquareError extends Error {
7
11
  this.name = 'SquareError';
8
12
  }
9
13
  }
10
- export const WARMUP_HEADING = '## Warmup';
11
- export const WARMUP_MARKER = '<!-- square:warmup -->';
12
- export const ACTIVITIES_HEADING = '## Activities';
13
- export const ACTIVITIES_MARKER = '<!-- square:activities -->';
14
- export const ACT_MARKER_PREFIX = '<!-- square:act';
15
- export const CURRENT_FORMAT_VERSION = 3;
16
14
  export function formatHardCap(hardCap) {
17
15
  return hardCap === null ? '-1' : String(hardCap);
18
16
  }
@@ -1,13 +1,24 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir } from 'node:os';
2
4
  import { setTimeout as sleep } from 'node:timers/promises';
3
5
  import { fileURLToPath } from 'node:url';
4
6
  import { loadSquare } from './artifact.js';
5
- import { isDeliveryDelivered, isPendingNotification, planActNotifications, } from './delivery.js';
6
- import { recordNotificationFailure } from './notification-failures.js';
7
+ import { deriveDeliveryModel, isDeliveryDelivered, leaseOwnsNotification, planActNotifications, } from './delivery.js';
8
+ import { sessionInbox } from './inbox.js';
7
9
  import { hasPresentedAttention } from './presented.js';
8
- import { SquareError } from './model.js';
10
+ import { nameKey, SquareError } from './model.js';
9
11
  import { SLEEP_MS, matchesMentionTarget, resolveRosterName, rosterNames } from './runtime.js';
10
- import { defaultWakeSinks } from './paseo-delivery.js';
12
+ import { formatActivityId, parseActivityId } from './square-core.js';
13
+ import { PaseoAdapter } from './paseo-delivery.js';
14
+ import { quoteShell } from './presentation.js';
15
+ import { lookupParticipant } from './registry.js';
16
+ import { isCurrentlyJoined } from './runtime.js';
17
+ import { execute } from './square-application.js';
18
+ import { nextWakeAttemptNumber, recordRecoveredUnknown, recordWakeAttempt, } from './wake-attempts.js';
19
+ import { joinedRecipients, wakeEvidence, wakeIsEligible } from './wake-evidence.js';
20
+ import { WakePort } from './wake-port.js';
21
+ const NOTIFY_LEASE_MS = 5 * 60 * 1000;
11
22
  export { planActNotifications, matchesMentionTarget };
12
23
  function known(doc, name) {
13
24
  const value = resolveRosterName(doc, name);
@@ -16,20 +27,70 @@ function known(doc, name) {
16
27
  return value;
17
28
  }
18
29
  export { notificationMessageId } from './delivery.js';
19
- export function notificationDeliveryWaitMs() {
20
- const value = Number.parseInt(process.env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? '5000', 10);
21
- if (!Number.isFinite(value) || value <= 0)
30
+ export function wakeGraceMs(env = process.env) {
31
+ const value = Number.parseInt(env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? '5000', 10);
32
+ if (!Number.isFinite(value) || value <= 0) {
22
33
  throw new SquareError('invalid_args', 'Invalid SQUARE_NOTIFY_DELIVERY_WAIT_MS: expected a positive integer.');
34
+ }
23
35
  return value;
24
36
  }
37
+ function catchCommand(squarePath, recipient) {
38
+ return `square --as ${quoteShell(recipient)} --location ${quoteShell(squarePath)} catch --now`;
39
+ }
40
+ function renderWakePayload(request) {
41
+ const display = request.squarePath.startsWith(homedir())
42
+ ? `~${request.squarePath.slice(homedir().length)}`
43
+ : request.squarePath;
44
+ return [
45
+ '<system-reminder source="square">',
46
+ `${request.route === 'bell' ? 'Bell' : 'Mention'} from @${request.actor} in \`${display}\``,
47
+ 'The native adapter will present it at the next boundary. If no native wake is available, pull from the square yourself.',
48
+ `\`${catchCommand(request.squarePath, request.recipient)}\``,
49
+ '</system-reminder>',
50
+ ].join('\n');
51
+ }
52
+ async function waitForCatch(route, request, body) {
53
+ const binding = lookupParticipant(request.squarePath, request.recipient)
54
+ .find((item) => item.ownerId === route.ownerId);
55
+ const activeCatch = binding && sessionInbox(binding.sessionId)
56
+ .find((item) => item.name === request.recipient)?.catchLease;
57
+ if (!activeCatch || !leaseOwnsNotification(activeCatch, {
58
+ actor: request.actor,
59
+ body,
60
+ route: request.route,
61
+ }))
62
+ return false;
63
+ const deadline = Date.now() + 180_000;
64
+ while (Date.now() < deadline) {
65
+ const doc = loadSquare(request.squarePath);
66
+ if (isDeliveryDelivered(doc, request.recipient, request.actIndex))
67
+ return true;
68
+ const currentBinding = lookupParticipant(request.squarePath, request.recipient)
69
+ .find((item) => item.ownerId === route.ownerId);
70
+ const lease = currentBinding && sessionInbox(currentBinding.sessionId)
71
+ .find((item) => item.name === request.recipient)?.catchLease;
72
+ if (!lease || lease.expiresAt <= Date.now())
73
+ return false;
74
+ await sleep(Math.min(250, lease.expiresAt - Date.now()));
75
+ }
76
+ return false;
77
+ }
78
+ function notificationIndex(ref) {
79
+ if (typeof ref === 'number')
80
+ return ref;
81
+ const index = parseActivityId(ref);
82
+ if (index === undefined)
83
+ throw new Error(`Invalid act ref: ${ref}`);
84
+ return index;
85
+ }
25
86
  export function hasDeliveredNotification(squarePath, name, ref) {
26
87
  const doc = loadSquare(squarePath);
27
- return isDeliveryDelivered(doc, known(doc, name), typeof ref === 'number' ? ref : Number(ref.slice(4)));
88
+ return isDeliveryDelivered(doc, known(doc, name), notificationIndex(ref));
28
89
  }
29
90
  export function hasAttentionNotification(squarePath, name, ref, env = process.env) {
30
91
  const doc = loadSquare(squarePath);
31
92
  const recipient = known(doc, name);
32
- const index = typeof ref === 'number' ? ref : Number(ref.slice(4));
93
+ const index = notificationIndex(ref);
33
94
  return isDeliveryDelivered(doc, recipient, index) || hasPresentedAttention(squarePath, recipient, index, env);
34
95
  }
35
96
  export async function waitForDeliveredNotification(squarePath, name, ref, opts = {}) {
@@ -41,31 +102,128 @@ export async function waitForDeliveredNotification(squarePath, name, ref, opts =
41
102
  }
42
103
  return false;
43
104
  }
105
+ function notifyLeaseKey(recipient, actIndex) {
106
+ return JSON.stringify([formatActivityId(actIndex), nameKey(recipient)]);
107
+ }
108
+ async function claimNotifyLease(squarePath, recipient, actIndex) {
109
+ const at = Date.now();
110
+ const committed = await execute(squarePath, {
111
+ type: 'claim-notify',
112
+ key: notifyLeaseKey(recipient, actIndex),
113
+ leaseId: randomUUID(),
114
+ at,
115
+ expiresAt: at + NOTIFY_LEASE_MS,
116
+ });
117
+ return committed.result;
118
+ }
119
+ async function transitionNotifyLease(squarePath, recipient, actIndex, leaseId, phase, routeKind, attemptN) {
120
+ const at = Date.now();
121
+ const committed = await execute(squarePath, {
122
+ type: 'transition-notify',
123
+ key: notifyLeaseKey(recipient, actIndex),
124
+ leaseId,
125
+ expiresAt: at + NOTIFY_LEASE_MS,
126
+ phase,
127
+ ...(routeKind === undefined ? {} : { routeKind }),
128
+ ...(attemptN === undefined ? {} : { attemptN }),
129
+ });
130
+ return committed.result.updated;
131
+ }
132
+ function releaseNotifyLease(squarePath, recipient, actIndex, leaseId) {
133
+ return execute(squarePath, {
134
+ type: 'release-notify',
135
+ key: notifyLeaseKey(recipient, actIndex),
136
+ leaseId,
137
+ });
138
+ }
139
+ async function processNotification(squarePath, notification, opts) {
140
+ const env = opts.env ?? process.env;
141
+ const now = opts.now ?? Date.now;
142
+ const attention = {
143
+ squarePath,
144
+ actIndex: notification.item.index,
145
+ recipient: notification.recipient,
146
+ };
147
+ const initialAt = now();
148
+ if (!wakeIsEligible(wakeEvidence(squarePath, notification.recipient, notification.item.index, initialAt, env)))
149
+ return;
150
+ const claim = await claimNotifyLease(squarePath, notification.recipient, notification.item.index);
151
+ if (claim.type === 'busy')
152
+ return;
153
+ if (claim.type === 'ambiguous') {
154
+ const recovered = recordRecoveredUnknown(attention, claim.lease, env);
155
+ if (recovered !== undefined) {
156
+ await releaseNotifyLease(squarePath, notification.recipient, notification.item.index, claim.lease.leaseId);
157
+ }
158
+ return;
159
+ }
160
+ const { leaseId } = claim;
161
+ let releaseLease = true;
162
+ try {
163
+ const dispatchAt = now();
164
+ const evidence = wakeEvidence(squarePath, notification.recipient, notification.item.index, dispatchAt, env);
165
+ if (!wakeIsEligible(evidence))
166
+ return;
167
+ const port = new WakePort(opts.adapters ?? [new PaseoAdapter()]);
168
+ const request = {
169
+ squarePath,
170
+ actIndex: notification.item.index,
171
+ recipient: notification.recipient,
172
+ actor: notification.item.actor,
173
+ route: notification.route,
174
+ };
175
+ await port.dispatch(evidence.attemptableRoutes, renderWakePayload(request), {
176
+ nextAttemptN: () => nextWakeAttemptNumber(attention, { env, now: now() }),
177
+ beforeSend: async (route, attemptN) => {
178
+ if (await waitForCatch(route, request, notification.item.body))
179
+ return false;
180
+ const currentAt = now();
181
+ const latest = loadSquare(squarePath);
182
+ if (!isCurrentlyJoined(latest.acts, notification.recipient))
183
+ return false;
184
+ const current = wakeEvidence(squarePath, notification.recipient, notification.item.index, currentAt, env);
185
+ if (!wakeIsEligible(current))
186
+ return false;
187
+ if (!current.attemptableRoutes.some((candidate) => candidate.ownerId === route.ownerId && candidate.kind === route.kind && candidate.sessionId === route.sessionId))
188
+ return false;
189
+ const dispatching = await transitionNotifyLease(squarePath, notification.recipient, notification.item.index, leaseId, 'dispatching', route.kind, attemptN);
190
+ if (dispatching)
191
+ releaseLease = false;
192
+ return dispatching;
193
+ },
194
+ record: async (route, attemptN, outcome) => {
195
+ if (outcome.outcome === 'failed') {
196
+ await transitionNotifyLease(squarePath, notification.recipient, notification.item.index, leaseId, 'claimed');
197
+ releaseLease = true;
198
+ }
199
+ recordWakeAttempt({
200
+ attention,
201
+ routeKind: route.kind,
202
+ outcome: outcome.outcome,
203
+ attemptN,
204
+ at: now(),
205
+ ...('signature' in outcome ? { signature: outcome.signature } : {}),
206
+ ...('message' in outcome ? { message: outcome.message } : {}),
207
+ ...('diagnostic' in outcome && outcome.diagnostic !== undefined ? { diagnostic: outcome.diagnostic } : {}),
208
+ }, env);
209
+ if (outcome.outcome !== 'failed')
210
+ releaseLease = true;
211
+ },
212
+ });
213
+ }
214
+ finally {
215
+ if (releaseLease) {
216
+ await releaseNotifyLease(squarePath, notification.recipient, notification.item.index, leaseId);
217
+ }
218
+ }
219
+ }
44
220
  export async function processActNotificationsOnce(squarePath, actIndex, opts = {}) {
45
221
  const doc = loadSquare(squarePath);
46
222
  const item = doc.acts.find((candidate) => candidate.index === actIndex);
47
223
  if (item === undefined)
48
224
  return;
49
- const notifications = planActNotifications(doc, item).filter(isPendingNotification);
50
- for (const notification of notifications) {
51
- if (hasAttentionNotification(squarePath, notification.recipient, notification.item.index))
52
- continue;
53
- for (const sink of opts.sinks ?? defaultWakeSinks()) {
54
- try {
55
- await sink.dispatch(notification, { squarePath });
56
- }
57
- catch (error) {
58
- recordNotificationFailure(squarePath, {
59
- actIndex: notification.item.index,
60
- recipient: notification.recipient,
61
- route: notification.route,
62
- sink: sink.name,
63
- message: error instanceof Error ? error.message : String(error),
64
- ...(error instanceof Error && 'diagnostic' in error ? { diagnostic: error.diagnostic } : {}),
65
- });
66
- }
67
- }
68
- }
225
+ const notifications = planActNotifications(doc, item);
226
+ await Promise.all(notifications.map((notification) => processNotification(squarePath, notification, opts)));
69
227
  }
70
228
  function launchWorker(workerPath, args) {
71
229
  const child = spawn(process.execPath, [workerPath, ...args], { detached: true, stdio: 'ignore', env: process.env });
@@ -73,10 +231,37 @@ function launchWorker(workerPath, args) {
73
231
  }
74
232
  /** Start one detached worker only when this act contains directed attention. */
75
233
  export async function dispatchActNotifications(squarePath, item, opts = {}) {
76
- if (process.env.SQUARE_DISABLE_PASEO_WAKE === '1')
234
+ const env = opts.env ?? process.env;
235
+ if (env.SQUARE_DISABLE_PASEO_WAKE === '1')
77
236
  return;
78
237
  const doc = loadSquare(squarePath);
79
- if (!planActNotifications(doc, item).some(isPendingNotification))
238
+ if (planActNotifications(doc, item).length === 0)
80
239
  return;
81
- (opts.launchWorker ?? launchWorker)(fileURLToPath(new URL('./cmd/notify-once.js', import.meta.url)), ['--square-path', squarePath, '--act-index', String(item.index)]);
240
+ (opts.launchWorker ?? launchWorker)(fileURLToPath(new URL('./cmd/notify-once.js', import.meta.url)), ['--location', squarePath, '--act-index', String(item.index)]);
241
+ }
242
+ /** Reconsider old pending attention at a bounded action boundary using the existing worker. */
243
+ export function sweepPendingNotifications(squarePath, opts = {}) {
244
+ const env = opts.env ?? process.env;
245
+ if (env.SQUARE_DISABLE_PASEO_WAKE === '1')
246
+ return [];
247
+ const now = opts.now ?? Date.now();
248
+ const limit = opts.limit ?? 8;
249
+ const doc = loadSquare(squarePath);
250
+ const model = deriveDeliveryModel(doc);
251
+ const indexes = new Set();
252
+ for (const recipient of joinedRecipients(doc)) {
253
+ for (const note of model.pendingFor(recipient)) {
254
+ if (now - note.item.at <= wakeGraceMs(env))
255
+ continue;
256
+ if (!wakeIsEligible(wakeEvidence(squarePath, recipient, note.item.index, now, env)))
257
+ continue;
258
+ indexes.add(note.item.index);
259
+ }
260
+ }
261
+ const selected = [...indexes].sort((a, b) => a - b).slice(0, Math.max(0, limit));
262
+ const workerPath = fileURLToPath(new URL('./cmd/notify-once.js', import.meta.url));
263
+ for (const actIndex of selected) {
264
+ (opts.launchWorker ?? launchWorker)(workerPath, ['--location', squarePath, '--act-index', String(actIndex)]);
265
+ }
266
+ return selected;
82
267
  }