@astrosheep/square 0.3.4 → 0.3.6

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 (52) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +3 -2
  2. package/dist/activity-feed.js +26 -18
  3. package/dist/activity.js +23 -22
  4. package/dist/artifact.js +126 -202
  5. package/dist/claude-hook.js +45 -21
  6. package/dist/cli/context.js +143 -0
  7. package/dist/cli/harness-command.js +50 -0
  8. package/dist/cli/maintenance-commands.js +76 -0
  9. package/dist/cli/meta-commands.js +28 -0
  10. package/dist/cli/observation-commands.js +453 -0
  11. package/dist/cli/program.js +48 -0
  12. package/dist/cli/registry.js +40 -0
  13. package/dist/cli/square-commands.js +219 -0
  14. package/dist/cmd/notify-once.js +23 -21
  15. package/dist/compact.js +6 -19
  16. package/dist/decisions.js +53 -86
  17. package/dist/delivery-health.js +104 -210
  18. package/dist/delivery.js +68 -18
  19. package/dist/doctor.js +9 -8
  20. package/dist/harness-claude.js +68 -0
  21. package/dist/harness-codex.js +119 -0
  22. package/dist/harness-links.js +123 -0
  23. package/dist/harness-stage.js +36 -0
  24. package/dist/harness.js +94 -576
  25. package/dist/help.js +44 -35
  26. package/dist/inbox.js +12 -11
  27. package/dist/index.js +30 -129
  28. package/dist/list.js +1 -1
  29. package/dist/model.js +0 -6
  30. package/dist/notification-failures.js +54 -0
  31. package/dist/notifications.js +47 -62
  32. package/dist/paseo-timeline.js +58 -188
  33. package/dist/presentation.js +55 -63
  34. package/dist/presented.js +9 -8
  35. package/dist/registry.js +55 -45
  36. package/dist/runtime.js +26 -137
  37. package/dist/square-application.js +264 -0
  38. package/dist/square-core.js +3 -11
  39. package/dist/square.js +5 -1362
  40. package/dist/stream.js +27 -126
  41. package/dist/wake-sink.js +134 -188
  42. package/dist/watch.js +79 -138
  43. package/extensions/square-opencode.js +1 -1
  44. package/extensions/square-pi.js +8 -130
  45. package/guides/architect.md +3 -3
  46. package/guides/participant.md +25 -16
  47. package/package.json +2 -2
  48. package/skills/brainstorm/SKILL.md +25 -32
  49. package/skills/square/.claude-plugin/plugin.json +1 -1
  50. package/skills/square/SKILL.md +39 -107
  51. package/skills/square-feedback/SKILL.md +4 -4
  52. package/dist/terminal.js +0 -125
package/dist/help.js CHANGED
@@ -1,61 +1,62 @@
1
1
  const COMMANDS = [
2
2
  { names: ['help'], usage: 'help [command]', summary: 'Show the command index or help for one command.' },
3
- { names: ['version'], usage: 'version', summary: 'Print the installed version.' },
3
+ { names: ['version'], usage: 'version', summary: 'Print the installed version.', hiddenFromIndex: true },
4
4
  {
5
- names: ['build'], usage: 'build --cap <N|-1> [--template <name>] [--throttle N] [-f] < body.md', usesSquare: true,
5
+ names: ['build'], usage: 'build [--cap <N|unlimited>] [--template <name>] [--throttle N] [-f] < body.md', usesSquare: true, group: 'host',
6
6
  summary: 'Create a square from Markdown on stdin.',
7
- details: ['Options:', ' --cap <N|-1> Required activity cap; -1 means unlimited.', ' --template <name> Append a packaged activity guide.', ' --throttle <N> Allow at most N public activities per minute.', ' -f, --force Replace an existing artifact.'],
7
+ details: ['Options:', ' --cap <N|unlimited> Set a per-participant activity cap (default unlimited).', ' --template <name> Append a packaged activity guide.', ' --throttle <N> Allow at most N public activities per minute.', ' -f, --force Replace an existing artifact.'],
8
8
  },
9
9
  {
10
- names: ['ls', 'list'], usage: '{command} [--depth N]', summary: 'List nearby squares below the current directory.',
10
+ names: ['list', 'ls'], usage: '{command} [--depth N]', summary: 'List nearby squares below the current directory.', group: 'host',
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,
14
+ names: ['join'], usage: '--as <name> join [--last N | --all]', usesSquare: true, group: 'participant',
15
15
  summary: 'Step into the square and read its current context.',
16
- details: ['Options:', ' --last <N> Show the last N public activities (default 10).', ' --all Show the full public archive.'],
16
+ details: ['Options:', ' --last <N> Show the last N public activities (default 10).', ' --all Show the complete history.'],
17
17
  },
18
18
  {
19
- names: ['act'], usage: '--as <name> act [-f|--force] [--no-wait] [--beside <name> | --bell] <activity | ->', usesSquare: true,
20
- summary: 'Add one public activity; pass - to read a multi-line body from stdin.',
21
- details: ['Options:', ' -f, --force Speak over older unread peer activity.', ' --no-wait Return through a hold or throttle lull, preserving a draft.', ' --beside <name> Let only that participant hear the full body.', ' --bell Ring every participant once within the bell window.'],
19
+ names: ['express'], usage: '--as <name> express [-f|--force] [--no-wait] [--beside <name> | --bell] <activity | ->', usesSquare: true, group: 'participant',
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."],
22
22
  },
23
23
  {
24
- names: ['catch'], usage: '--as <name> catch [--now | --follow] [--count N] [--by <names>] [--mention [name]] [--idle <duration>] [-f|--force]', usesSquare: true,
25
- summary: 'Catch peer activity and advance this participant\'s presence.',
26
- details: ['Options:', ' --now Catch up immediately.', ' --follow Keep catching until idle or a terminal state.', ' --count <N> Return after N matching peer activities.', ' --by <names> Match only comma-separated participants.', ' --mention [name] Match mentions of a name, or your own name when omitted.', ' --idle <duration> Set the idle wait, for example 30s, 10m, or 1h.', ' -f, --force Replace another active catch for this participant.'],
24
+ names: ['catch'], usage: '--as <name> catch (--now | --idle <duration>) [--from <names>] [--mention [name]] [--replace]', usesSquare: true, group: 'participant',
25
+ summary: 'Catch what others have said or done since you last looked.',
26
+ details: ['Modes:', ' --now Catch up immediately.', ' --idle <duration> Wait for something relevant, or for quiet to last this long.', '', 'Filters:', ' --from <names> Match only comma-separated participants.', ' --mention [name] Match mentions of a name, or your own name when omitted.', '', 'Recovery:', ' --replace Replace another active catch for this participant.'],
27
27
  },
28
- { names: ['done'], usage: '--as <name> done [final | -]', usesSquare: true, summary: 'Step out, optionally leaving a final note.' },
28
+ { names: ['done'], usage: '--as <name> done [final | -]', usesSquare: true, group: 'participant', summary: 'Step out, optionally leaving a final note.' },
29
29
  {
30
- names: ['stream'], usage: 'stream [--ndjson [--for <name>]]', usesSquare: true,
30
+ names: ['stream'], usage: 'stream [--ndjson [--for <name>]]', usesSquare: true, hiddenFromIndex: true,
31
31
  summary: 'Follow activity without consuming participant presence.',
32
32
  details: ['Options:', ' --ndjson Emit one JSON event per line.', ' --for <name> With --ndjson, emit notifications for one participant.'],
33
33
  },
34
34
  {
35
- names: ['inbox'], usage: 'inbox --for-session <session-id> [--json]',
35
+ names: ['inbox'], usage: 'inbox --for-session <session-id> [--json]', hiddenFromIndex: true,
36
36
  summary: 'Inspect bounded machine-local notifications for a native session.',
37
37
  details: ['Options:', ' --for-session <id> Required harness session id.', ' --json Emit structured JSON.'],
38
38
  },
39
- { names: ['claude-hook', 'codex-hook'], usage: '{command}', summary: 'Run one native turn-boundary hook event from JSON on stdin.' },
39
+ { names: ['claude-hook', 'codex-hook'], usage: '{command}', summary: 'Run one native turn-boundary hook event from JSON on stdin.', hiddenFromIndex: true },
40
40
  {
41
- names: ['echo'], usage: '[--as <name>] echo [filters] [output]', usesSquare: true,
42
- summary: 'Read the activity archive without advancing participant presence.',
43
- details: ['Filters:', ' --from, --by <names> Match participants.', ' --since, --until <time> Match a time window.', ' --grep <regex> | --fixed <s> Search activity bodies.', ' --mention <name> Match mentions.', ' --mentions me | --pending Match attention 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.', ' --order <asc|desc> Set result order.', ' --last, --limit <N> | --all Bound the result count.', '', 'Output:', ' --full --json --format <fields> --count'],
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'],
44
44
  },
45
- { names: ['warmup'], usage: 'warmup', usesSquare: true, summary: 'Print the complete embedded participant warmup.' },
46
- { names: ['status'], usage: '[--as <name>] status', usesSquare: true, summary: 'Show the current state and latest public activity.' },
47
- { names: ['participants'], usage: 'participants', usesSquare: true, summary: 'Show the participant roster and current states.' },
48
- { names: ['hold'], usage: '--as <name> hold [reason | -]', usesSquare: true, summary: 'Raise a hand and pause participant activity.' },
49
- { names: ['resume'], usage: '--as <name> resume', usesSquare: true, summary: 'Lower the raised hand and resume activity.' },
45
+ { names: ['warmup'], usage: 'warmup', usesSquare: true, group: 'host', summary: 'Print the complete embedded participant warmup.' },
46
+ { names: ['status'], usage: '[--as <name>] status', usesSquare: true, group: 'participant', summary: 'Show who is present and what happened most recently.' },
47
+ { names: ['participants'], usage: 'participants', usesSquare: true, group: 'host', summary: 'Show the full participant roster and current states.' },
48
+ { names: ['hold'], usage: '--as <name> hold [reason | -]', usesSquare: true, group: 'participant', summary: 'Raise a hand and pause participant activity.' },
49
+ { names: ['resume'], usage: '--as <name> resume', usesSquare: true, group: 'participant', summary: 'Lower the raised hand and resume activity.' },
50
50
  {
51
- names: ['harness'], usage: 'harness <install <skills|claude|codex|opencode|pi> [-f] | uninstall codex | doctor [codex|opencode|delivery]>', usesSquare: true,
51
+ names: ['harness'], usage: 'harness <install <skills|claude|codex|opencode|pi> [-f] | uninstall <skills|claude|codex|opencode|pi> | doctor [skills|claude|codex|opencode|pi|delivery]>', usesSquare: true, group: 'maintenance',
52
52
  summary: 'Install, remove, or diagnose official harness adapters.',
53
+ details: ['Targets:', ' skills, claude, codex, opencode, pi Install, remove, or diagnose one adapter.', ' delivery Diagnose delivery only; skips when no readable artifact is selected.'],
53
54
  },
54
- { names: ['compact'], usage: 'compact [--keep N]', usesSquare: true, summary: 'Archive older acts while retaining the latest N.' },
55
+ { names: ['compact'], usage: 'compact [--keep N]', usesSquare: true, group: 'host', summary: 'Move older activity out of the working artifact while keeping the latest N.' },
55
56
  {
56
- names: ['doctor'], usage: 'doctor [--fix] [reconcile-backlog [--before <time>]]', usesSquare: true,
57
- summary: 'Diagnose artifact integrity and delivery health.',
58
- details: ['Options:', ' --fix Repair recoverable artifact problems.', ' reconcile-backlog Close historical delivery acknowledgement debt.', ' --before <time> Record the intended reconciliation boundary.'],
57
+ names: ['doctor'], usage: 'doctor [--fix]', usesSquare: true, group: 'maintenance',
58
+ summary: 'Diagnose artifact integrity.',
59
+ details: ['Options:', ' --fix Repair recoverable artifact problems.'],
59
60
  },
60
61
  ];
61
62
  function definitionFor(command) {
@@ -65,15 +66,23 @@ function isHelpFlag(value) {
65
66
  return value === '--help' || value === '-h';
66
67
  }
67
68
  export function renderGlobalHelp() {
68
- const commandLines = COMMANDS
69
- .filter((item) => item.names[0] !== 'help')
70
- .map((item) => ` ${item.names.join(', ')}\n ${item.summary}`);
69
+ const groups = [
70
+ { key: 'participant', title: 'In the square:', order: ['join', 'express', 'catch', 'history', 'status', 'hold', 'resume', 'done'] },
71
+ { key: 'host', title: 'Prepare and manage:', order: ['build', 'list', 'participants', 'warmup', 'compact'] },
72
+ { key: 'maintenance', title: 'Setup and repair:', order: ['harness', 'doctor'] },
73
+ ];
74
+ const commandLines = groups.flatMap(({ key, title, order }) => [
75
+ title,
76
+ ...COMMANDS
77
+ .filter((item) => item.group === key && item.hiddenFromIndex !== true)
78
+ .sort((left, right) => order.indexOf(left.names[0]) - order.indexOf(right.names[0]))
79
+ .map((item) => ` ${item.names[0]}\n ${item.summary}`),
80
+ '',
81
+ ]);
71
82
  return [
72
83
  'Usage: square [--square-path <path>] [--as <name>] <command> [args...]',
73
84
  '',
74
- 'Commands:',
75
85
  ...commandLines,
76
- '',
77
86
  "Run 'square <command> --help' for command options.",
78
87
  '',
79
88
  ].join('\n');
@@ -94,7 +103,7 @@ export function renderSubcommandHelp(command) {
94
103
  'Help:',
95
104
  ' -h, --help Show this command help.',
96
105
  '',
97
- "Run 'square help' to list every command.",
106
+ "Run 'square help' to list available commands.",
98
107
  '',
99
108
  ].join('\n');
100
109
  }
package/dist/inbox.js CHANGED
@@ -1,26 +1,27 @@
1
1
  import { loadSquare } from './artifact.js';
2
2
  import { deriveDeliveryModel } from './delivery.js';
3
- import { lookupSession } from './registry.js';
3
+ import { lookupSessionBindings } from './registry.js';
4
4
  import { freshWatchLease, isCurrentlyJoined, resolveRosterName } from './runtime.js';
5
5
  export function sessionInbox(sessionId) {
6
6
  const inbox = [];
7
- for (const membership of lookupSession(sessionId)) {
7
+ for (const binding of lookupSessionBindings(sessionId)) {
8
8
  try {
9
- const doc = loadSquare(membership.squarePath);
10
- const name = resolveRosterName(doc, membership.name);
9
+ const doc = loadSquare(binding.squarePath);
10
+ const name = resolveRosterName(doc, binding.name);
11
11
  if (!name || !isCurrentlyJoined(doc.acts, name))
12
12
  continue;
13
- const notifications = deriveDeliveryModel(doc).pendingFor(name).map(({ item, via }) => ({
13
+ const notifications = deriveDeliveryModel(doc).pendingFor(name).map(({ item, route }) => ({
14
14
  actIndex: item.index,
15
- actor: item.act.actor,
16
- at: item.act.at,
17
- via,
18
- body: item.act.body,
15
+ actor: item.actor,
16
+ at: item.at,
17
+ route,
18
+ body: item.body,
19
19
  }));
20
- const catchLease = freshWatchLease(doc, name);
20
+ const lease = freshWatchLease(doc, name);
21
+ const catchLease = lease?.ownerId === binding.ownerId ? lease : undefined;
21
22
  inbox.push({
22
23
  name,
23
- squarePath: membership.squarePath,
24
+ squarePath: binding.squarePath,
24
25
  notifications,
25
26
  ...(catchLease !== undefined ? { catchLease } : {}),
26
27
  });
package/dist/index.js CHANGED
@@ -1,25 +1,11 @@
1
- import { setTimeout as sleep } from 'node:timers/promises';
2
- export * from './model.js';
3
- export * from './square-core.js';
4
- export * from './decisions.js';
1
+ export { SquareError } from './model.js';
5
2
  export { loadSquare } from './artifact.js';
6
- export { indexedDelta } from './activity-feed.js';
7
- export * from './notifications.js';
8
- export * from './wake-sink.js';
9
- export * from './paseo-timeline.js';
10
- export * from './registry.js';
11
- export * from './inbox.js';
12
- export * from './presented.js';
13
- export * from './delivery-health.js';
14
- export * from './claude-hook.js';
15
- export * from './harness.js';
16
- export { extractMentions, countSays, joinedNames, doneNames, isCurrentlyJoined, publicActs, appendAct, withSquareLock, readCursor, } from './runtime.js';
3
+ export { extractMentions, countSays, joinedNames, doneNames, isCurrentlyJoined, publicActs, readCursor, } from './runtime.js';
17
4
  import { loadSquare } from './artifact.js';
18
- import { ackPeerDelta, indexedDelta, peerPublicActs, peerRoomChanges } from './activity-feed.js';
19
- import { dispatchActNotifications, hasDeliveredMention as hasDeliveredMentionImpl, matchesMentionTarget, waitForDeliveredMention as waitForDeliveredMentionImpl, } from './notifications.js';
20
- import { sameName } from './model.js';
21
- import { SLEEP_MS, WATCH_HEARTBEAT_MS, WATCH_STALE_MS, actStableIndex, appendAct, freshWatchLease, getReadState as getDocReadState, latestIndexedActIndex, markDeliveredMentions, readCursor, withSquareLock, writeSquareDoc, } from './runtime.js';
22
- import { decideAct, resolveKnownName } from './decisions.js';
5
+ import { hasDeliveredNotification as hasDeliveredNotificationImpl, waitForDeliveredNotification as waitForDeliveredNotificationImpl, } from './notifications.js';
6
+ import { WATCH_STALE_MS, freshWatchLease, getReadState as getDocReadState, } from './runtime.js';
7
+ import { resolveKnownName } from './decisions.js';
8
+ import { execute } from './square-application.js';
23
9
  export { WATCH_STALE_MS };
24
10
  function actRefIndex(ref) {
25
11
  if (typeof ref === 'number')
@@ -29,22 +15,6 @@ function actRefIndex(ref) {
29
15
  throw new Error(`Invalid act ref: ${ref}`);
30
16
  return Number(match[1]);
31
17
  }
32
- function byList(by) {
33
- if (by === undefined)
34
- return undefined;
35
- return Array.isArray(by) ? by : by.split(',').map((item) => item.trim()).filter(Boolean);
36
- }
37
- function matchesProgrammaticFilter(item, opts) {
38
- const speakers = byList(opts.by);
39
- if (speakers !== undefined && (item.act.actor === undefined || !speakers.some((speaker) => sameName(speaker, item.act.actor))))
40
- return false;
41
- if (opts.mention !== undefined) {
42
- if (item.act.kind !== 'say')
43
- return false;
44
- return matchesMentionTarget(item.act, opts.mention);
45
- }
46
- return item.act.kind === 'say' || item.act.kind === 'done' || item.act.kind === 'join';
47
- }
48
18
  export function getReadState(squarePath, name) {
49
19
  const doc = loadSquare(squarePath);
50
20
  return getDocReadState(doc, resolveKnownName(doc, name));
@@ -53,11 +23,11 @@ export function hasConsumedAct(squarePath, name, ref) {
53
23
  const state = getReadState(squarePath, name);
54
24
  return (state?.consumedThroughIndex ?? -1) >= actRefIndex(ref);
55
25
  }
56
- export function hasDeliveredMention(squarePath, name, ref) {
57
- return hasDeliveredMentionImpl(squarePath, name, actRefIndex(ref));
26
+ export function hasDeliveredNotification(squarePath, name, ref) {
27
+ return hasDeliveredNotificationImpl(squarePath, name, actRefIndex(ref));
58
28
  }
59
- export async function waitForDeliveredMention(squarePath, name, ref, opts = {}) {
60
- return waitForDeliveredMentionImpl(squarePath, name, actRefIndex(ref), opts);
29
+ export async function waitForDeliveredNotification(squarePath, name, ref, opts = {}) {
30
+ return waitForDeliveredNotificationImpl(squarePath, name, actRefIndex(ref), opts);
61
31
  }
62
32
  export function getParticipantPresence(squarePath, name, now = Date.now()) {
63
33
  const doc = loadSquare(squarePath);
@@ -68,96 +38,27 @@ export function getParticipantPresence(squarePath, name, now = Date.now()) {
68
38
  export function isWatching(squarePath, name, now = Date.now()) {
69
39
  return getParticipantPresence(squarePath, name, now).watching;
70
40
  }
71
- export async function act(squarePath, name, body, opts = {}) {
72
- const sent = await withSquareLock(squarePath, () => {
73
- const doc = loadSquare(squarePath);
74
- const decision = decideAct(doc, { name, body, force: opts.force ?? false, now: Date.now() });
75
- if (decision.type === 'sent') {
76
- const appended = appendAct(squarePath, doc, decision.act);
77
- return { act: appended, index: actStableIndex(appended) };
78
- }
79
- throw new Error(`Act rejected: ${decision.type}`);
80
- });
81
- if (sent)
82
- await dispatchActNotifications(squarePath, sent);
41
+ /** Typed participant intents share the same commit/effect pipeline as the CLI. */
42
+ export async function join(squarePath, name) {
43
+ await execute(squarePath, { type: 'join', name, now: Date.now() });
44
+ }
45
+ export async function done(squarePath, name, body = '') {
46
+ await execute(squarePath, { type: 'done', name, body, now: Date.now() });
47
+ }
48
+ export async function hold(squarePath, actor, body = '') {
49
+ await execute(squarePath, { type: 'hold', actor, body, now: Date.now() });
83
50
  }
84
- export async function* streamEvents(squarePath, opts = {}) {
85
- let doc = loadSquare(squarePath);
86
- let cursor = latestIndexedActIndex(doc.acts.map((act) => ({ act, index: actStableIndex(act) })));
87
- while (true) {
88
- await sleep(SLEEP_MS);
89
- try {
90
- doc = loadSquare(squarePath);
91
- }
92
- catch {
93
- continue;
94
- }
95
- const delta = indexedDelta(doc.acts, cursor);
96
- if (delta.length === 0)
97
- continue;
98
- cursor = latestIndexedActIndex(delta);
99
- const filtered = delta.filter((item) => matchesProgrammaticFilter(item, opts));
100
- if (filtered.length > 0)
101
- yield filtered;
102
- }
51
+ export async function resume(squarePath, actor) {
52
+ await execute(squarePath, { type: 'resume', actor, now: Date.now() });
103
53
  }
104
- export async function* watch(squarePath, opts) {
105
- let doc = loadSquare(squarePath);
106
- const name = resolveKnownName(doc, opts.name);
107
- const leaseId = `watch_api_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
108
- let nextHeartbeatAt = Date.now() + WATCH_HEARTBEAT_MS;
109
- await withSquareLock(squarePath, () => {
110
- doc = loadSquare(squarePath);
111
- const existing = freshWatchLease(doc, name);
112
- if (existing !== undefined)
113
- throw new Error(`${name} already has an active watch.`);
114
- const at = Date.now();
115
- nextHeartbeatAt = at + WATCH_HEARTBEAT_MS;
116
- const participants = byList(opts.by);
117
- const filter = { ...(participants ? { participants } : {}), ...(typeof opts.mention === 'string' ? { mention: opts.mention } : {}) };
118
- doc.runtime.leases[name] = { leaseId, heartbeatAt: at, expiresAt: at + WATCH_STALE_MS, ...(Object.keys(filter).length > 0 ? { filter } : {}) };
119
- writeSquareDoc(squarePath, doc);
54
+ export async function express(squarePath, name, body, opts = {}) {
55
+ const committed = await execute(squarePath, {
56
+ type: 'say',
57
+ name,
58
+ body,
59
+ force: opts.force ?? false,
60
+ now: Date.now(),
120
61
  });
121
- try {
122
- while (true) {
123
- const yielded = await withSquareLock(squarePath, () => {
124
- const latest = loadSquare(squarePath);
125
- const at = Date.now();
126
- const lease = freshWatchLease(latest, name, at);
127
- if (lease?.leaseId !== leaseId)
128
- throw new Error(`${name}'s watch was replaced.`);
129
- let mutated = false;
130
- if (at >= nextHeartbeatAt) {
131
- latest.runtime.leases[name] = { ...lease, heartbeatAt: at, expiresAt: at + WATCH_STALE_MS };
132
- nextHeartbeatAt = at + WATCH_HEARTBEAT_MS;
133
- mutated = true;
134
- }
135
- const delta = indexedDelta(latest.acts, readCursor(latest, name));
136
- const deliverable = [...peerPublicActs(delta, name), ...peerRoomChanges(delta, name)];
137
- if (deliverable.length === 0) {
138
- if (mutated)
139
- writeSquareDoc(squarePath, latest);
140
- return null;
141
- }
142
- const filtered = deliverable.filter((item) => matchesProgrammaticFilter(item, opts));
143
- const consumed = ackPeerDelta(latest, name, delta);
144
- const receipts = markDeliveredMentions(latest, name, filtered);
145
- if (consumed || receipts || mutated)
146
- writeSquareDoc(squarePath, latest);
147
- return filtered.length > 0 ? filtered : null;
148
- });
149
- if (yielded !== null)
150
- yield yielded;
151
- await sleep(SLEEP_MS);
152
- }
153
- }
154
- finally {
155
- await withSquareLock(squarePath, () => {
156
- const latest = loadSquare(squarePath);
157
- if (latest.runtime.leases[name]?.leaseId !== leaseId)
158
- return;
159
- delete latest.runtime.leases[name];
160
- writeSquareDoc(squarePath, latest);
161
- });
162
- }
62
+ if (committed.result.type !== 'sent')
63
+ throw new Error(`Activity rejected: ${committed.result.type}`);
163
64
  }
package/dist/list.js CHANGED
@@ -102,7 +102,7 @@ function renderSquareList(items) {
102
102
  const now = Date.now();
103
103
  const lines = [
104
104
  'squares',
105
- ...items.map((item) => `${item.activities > 0 ? '●' : '○'} ${item.path} · ${formatRelativeTime(item.lastActiveAt, now)} · ${item.participants} in square · ${item.activities} acts`),
105
+ ...items.map((item) => `${item.activities > 0 ? '●' : '○'} ${item.path} · ${formatRelativeTime(item.lastActiveAt, now)} · ${item.participants} in square · ${item.activities} activities`),
106
106
  ];
107
107
  return lines.join('\n') + '\n';
108
108
  }
package/dist/model.js CHANGED
@@ -13,9 +13,6 @@ export const ACTIVITIES_HEADING = '## Activities';
13
13
  export const ACTIVITIES_MARKER = '<!-- square:activities -->';
14
14
  export const ACT_MARKER_PREFIX = '<!-- square:act';
15
15
  export const CURRENT_FORMAT_VERSION = 3;
16
- export function formatParticipants(participants) {
17
- return participants.join(', ');
18
- }
19
16
  export function formatHardCap(hardCap) {
20
17
  return hardCap === null ? '-1' : String(hardCap);
21
18
  }
@@ -39,6 +36,3 @@ export function validateName(name) {
39
36
  throw new SquareError('invalid_name', 'Invalid name: names must be non-empty and can only contain Unicode letters, digits, hyphens, and underscores.');
40
37
  }
41
38
  }
42
- export function validateParticipantName(name) {
43
- validateName(name);
44
- }
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const MAX_BYTES = 1_000_000;
4
+ export function notificationFailuresPath(squarePath, env = process.env) {
5
+ return env.SQUARE_NOTIFICATION_FAILURES ?? path.join(path.dirname(squarePath), 'notification-failures.ndjsonl');
6
+ }
7
+ function redact(value, secret = process.env.PASEO_PASSWORD) {
8
+ if (typeof value === 'string')
9
+ return secret ? value.split(secret).join('[redacted]') : value;
10
+ if (Array.isArray(value))
11
+ return value.map((item) => redact(item, secret));
12
+ if (value !== null && typeof value === 'object') {
13
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item, secret)]));
14
+ }
15
+ return value;
16
+ }
17
+ function parseRows(file) {
18
+ try {
19
+ return fs.readFileSync(file, 'utf8').split('\n').flatMap((line) => {
20
+ if (line.trim() === '')
21
+ return [];
22
+ try {
23
+ const row = JSON.parse(line);
24
+ return row.v === 1 && row.op === 'failed' && typeof row.actIndex === 'number' && typeof row.sink === 'string' && typeof row.message === 'string' && typeof row.at === 'number'
25
+ ? [{ actIndex: row.actIndex, recipient: row.recipient, route: row.route, sink: row.sink, message: row.message, at: row.at, ...(row.diagnostic === undefined ? {} : { diagnostic: row.diagnostic }) }]
26
+ : [];
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ });
32
+ }
33
+ catch (error) {
34
+ if (error.code === 'ENOENT')
35
+ return [];
36
+ throw error;
37
+ }
38
+ }
39
+ export function readNotificationFailures(squarePath, env = process.env) {
40
+ return parseRows(notificationFailuresPath(squarePath, env));
41
+ }
42
+ /** Append diagnosable delivery failures without ever persisting Paseo credentials. */
43
+ export function recordNotificationFailure(squarePath, input, at = Date.now(), env = process.env) {
44
+ const file = notificationFailuresPath(squarePath, env);
45
+ const safe = redact({ ...input, at }, env.PASEO_PASSWORD);
46
+ const row = { v: 1, op: 'failed', ...safe };
47
+ fs.mkdirSync(path.dirname(file), { recursive: true });
48
+ const text = `${JSON.stringify(row)}\n`;
49
+ if (fs.existsSync(file) && fs.statSync(file).size + Buffer.byteLength(text) > MAX_BYTES) {
50
+ const retained = parseRows(file).slice(-500);
51
+ fs.writeFileSync(file, retained.map((item) => JSON.stringify({ v: 1, op: 'failed', ...item })).join('\n') + (retained.length ? '\n' : ''), { mode: 0o600 });
52
+ }
53
+ fs.appendFileSync(file, text, { mode: 0o600 });
54
+ }
@@ -2,56 +2,40 @@ import { spawn } from 'node:child_process';
2
2
  import { setTimeout as sleep } from 'node:timers/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { loadSquare } from './artifact.js';
5
- import { SquareError } from './model.js';
5
+ import { isDeliveryDelivered, isPendingNotification, planActNotifications, } from './delivery.js';
6
+ import { recordNotificationFailure } from './notification-failures.js';
6
7
  import { hasPresentedAttention } from './presented.js';
7
- import { SLEEP_MS, isDeliveryDelivered, resolveRosterName, rosterNames, } from './runtime.js';
8
- import { planActNotifications } from './delivery.js';
9
- import { defaultWakeSinks, } from './wake-sink.js';
10
- export { planActNotifications } from './delivery.js';
11
- export { matchesMentionTarget } from './runtime.js';
12
- function parsePositiveIntegerEnv(name, fallback) {
13
- const raw = process.env[name];
14
- if (raw === undefined)
15
- return fallback;
16
- const value = Number.parseInt(raw, 10);
17
- if (!Number.isFinite(value) || value <= 0) {
18
- throw new SquareError('invalid_args', `Invalid ${name}: expected a positive integer.`);
19
- }
20
- return value;
21
- }
22
- function resolveKnownParticipant(doc, name) {
23
- const known = resolveRosterName(doc, name);
24
- if (known === undefined) {
8
+ import { SquareError } from './model.js';
9
+ import { SLEEP_MS, matchesMentionTarget, resolveRosterName, rosterNames } from './runtime.js';
10
+ import { defaultWakeSinks } from './wake-sink.js';
11
+ export { planActNotifications, matchesMentionTarget };
12
+ function known(doc, name) {
13
+ const value = resolveRosterName(doc, name);
14
+ if (value === undefined)
25
15
  throw new SquareError('invalid_args', `Unknown participant "${name}". Expected one of: ${rosterNames(doc).join(', ')}.`);
26
- }
27
- return known;
16
+ return value;
28
17
  }
18
+ export { notificationMessageId } from './delivery.js';
29
19
  export function notificationDeliveryWaitMs() {
30
- return parsePositiveIntegerEnv('SQUARE_NOTIFY_DELIVERY_WAIT_MS', 5000);
20
+ const value = Number.parseInt(process.env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? '5000', 10);
21
+ if (!Number.isFinite(value) || value <= 0)
22
+ throw new SquareError('invalid_args', 'Invalid SQUARE_NOTIFY_DELIVERY_WAIT_MS: expected a positive integer.');
23
+ return value;
31
24
  }
32
- export function hasDeliveredMention(squarePath, name, ref) {
25
+ export function hasDeliveredNotification(squarePath, name, ref) {
33
26
  const doc = loadSquare(squarePath);
34
- const known = resolveKnownParticipant(doc, name);
35
- const index = typeof ref === 'number' ? ref : Number(ref.slice(4));
36
- return isDeliveryDelivered(doc, known, index);
27
+ return isDeliveryDelivered(doc, known(doc, name), typeof ref === 'number' ? ref : Number(ref.slice(4)));
37
28
  }
38
- /**
39
- * Duplicate-wake suppression only. Delivery remains pending until the canonical
40
- * recipient/act receipt is delivered; presented is a machine-local cache.
41
- */
42
- export function hasAttentionMention(squarePath, name, ref, env = process.env) {
29
+ export function hasAttentionNotification(squarePath, name, ref, env = process.env) {
43
30
  const doc = loadSquare(squarePath);
44
- const known = resolveKnownParticipant(doc, name);
31
+ const recipient = known(doc, name);
45
32
  const index = typeof ref === 'number' ? ref : Number(ref.slice(4));
46
- if (isDeliveryDelivered(doc, known, index))
47
- return true;
48
- return hasPresentedAttention(squarePath, known, index, env);
33
+ return isDeliveryDelivered(doc, recipient, index) || hasPresentedAttention(squarePath, recipient, index, env);
49
34
  }
50
- export async function waitForDeliveredMention(squarePath, name, ref, opts = {}) {
51
- const timeoutMs = opts.timeoutMs ?? 30000;
52
- const deadline = Date.now() + timeoutMs;
35
+ export async function waitForDeliveredNotification(squarePath, name, ref, opts = {}) {
36
+ const deadline = Date.now() + (opts.timeoutMs ?? 30000);
53
37
  while (Date.now() <= deadline) {
54
- if (hasDeliveredMention(squarePath, name, ref))
38
+ if (hasDeliveredNotification(squarePath, name, ref))
55
39
  return true;
56
40
  await sleep(Math.min(SLEEP_MS, Math.max(1, deadline - Date.now())));
57
41
  }
@@ -59,39 +43,40 @@ export async function waitForDeliveredMention(squarePath, name, ref, opts = {})
59
43
  }
60
44
  export async function processActNotificationsOnce(squarePath, actIndex, opts = {}) {
61
45
  const doc = loadSquare(squarePath);
62
- const act = doc.acts.find((candidate) => candidate.index === actIndex);
63
- if (!act)
64
- return;
65
- const item = { act, index: actIndex };
66
- const notifications = planActNotifications(doc, item).filter((notification) => notification.via === 'mention' || notification.via === 'bell');
67
- const sinks = opts.sinks ?? defaultWakeSinks();
68
- if (sinks.length === 0)
46
+ const item = doc.acts.find((candidate) => candidate.index === actIndex);
47
+ if (item === undefined)
69
48
  return;
49
+ const notifications = planActNotifications(doc, item).filter(isPendingNotification);
70
50
  for (const notification of notifications) {
71
- // Presented only avoids duplicate wake text. It does not affect delivery state.
72
- if (hasAttentionMention(squarePath, notification.recipient, actIndex))
51
+ if (hasAttentionNotification(squarePath, notification.recipient, notification.item.index))
73
52
  continue;
74
- for (const sink of sinks) {
75
- await sink.dispatch(notification, { squarePath });
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
+ }
76
67
  }
77
68
  }
78
69
  }
79
- function launchDetachedWorker(workerPath, args) {
80
- const child = spawn(process.execPath, [workerPath, ...args], {
81
- detached: true,
82
- stdio: 'ignore',
83
- env: process.env,
84
- });
70
+ function launchWorker(workerPath, args) {
71
+ const child = spawn(process.execPath, [workerPath, ...args], { detached: true, stdio: 'ignore', env: process.env });
85
72
  child.unref();
86
73
  }
74
+ /** Start one detached worker only when this act contains directed attention. */
87
75
  export async function dispatchActNotifications(squarePath, item, opts = {}) {
88
- if (process.env['SQUARE_DISABLE_PASEO_WAKE'] === '1')
76
+ if (process.env.SQUARE_DISABLE_PASEO_WAKE === '1')
89
77
  return;
90
78
  const doc = loadSquare(squarePath);
91
- const notifications = planActNotifications(doc, item).filter((notification) => notification.via === 'mention' || notification.via === 'bell');
92
- if (notifications.length === 0)
79
+ if (!planActNotifications(doc, item).some(isPendingNotification))
93
80
  return;
94
- const workerPath = fileURLToPath(new URL('./cmd/notify-once.js', import.meta.url));
95
- const launch = opts.launchWorker ?? launchDetachedWorker;
96
- launch(workerPath, ['--square-path', squarePath, '--act-index', String(item.index)]);
81
+ (opts.launchWorker ?? launchWorker)(fileURLToPath(new URL('./cmd/notify-once.js', import.meta.url)), ['--square-path', squarePath, '--act-index', String(item.index)]);
97
82
  }