@astrosheep/square 0.3.2

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 (47) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +25 -0
  2. package/codex-plugin/hooks/hooks.json +28 -0
  3. package/dist/activity-feed.js +36 -0
  4. package/dist/activity.js +151 -0
  5. package/dist/artifact.js +739 -0
  6. package/dist/claude-hook.js +112 -0
  7. package/dist/cmd/notify-once.js +37 -0
  8. package/dist/compact.js +39 -0
  9. package/dist/decisions.js +286 -0
  10. package/dist/delivery-health.js +249 -0
  11. package/dist/delivery.js +93 -0
  12. package/dist/doctor.js +34 -0
  13. package/dist/harness.js +584 -0
  14. package/dist/help.js +131 -0
  15. package/dist/inbox.js +33 -0
  16. package/dist/index.js +163 -0
  17. package/dist/list.js +126 -0
  18. package/dist/model.js +44 -0
  19. package/dist/notifications.js +97 -0
  20. package/dist/paseo-timeline.js +206 -0
  21. package/dist/presentation.js +468 -0
  22. package/dist/presented.js +211 -0
  23. package/dist/registry.js +299 -0
  24. package/dist/runtime.js +304 -0
  25. package/dist/search.js +54 -0
  26. package/dist/square-core.js +183 -0
  27. package/dist/square.js +1366 -0
  28. package/dist/stream.js +149 -0
  29. package/dist/terminal.js +125 -0
  30. package/dist/time.js +81 -0
  31. package/dist/wake-sink.js +219 -0
  32. package/dist/watch.js +386 -0
  33. package/extensions/square-opencode.js +87 -0
  34. package/extensions/square-pi.js +167 -0
  35. package/guides/architect.md +165 -0
  36. package/guides/brainstorm.md +404 -0
  37. package/guides/participant.md +171 -0
  38. package/package.json +57 -0
  39. package/skills/brainstorm/SKILL.md +136 -0
  40. package/skills/square/.claude-plugin/plugin.json +8 -0
  41. package/skills/square/SKILL.md +154 -0
  42. package/skills/square/hooks/hooks.json +27 -0
  43. package/skills/square-feedback/SKILL.md +55 -0
  44. package/skills/square-feedback/agents/openai.yaml +4 -0
  45. package/template.md +4 -0
  46. package/templates/architect.md +4 -0
  47. package/templates/brainstorm.md +4 -0
package/dist/stream.js ADDED
@@ -0,0 +1,149 @@
1
+ // stream.ts — live activity feed
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { loadSquare } from './artifact.js';
6
+ import { planActNotifications } from './notifications.js';
7
+ import { sameName } from './model.js';
8
+ import { indexedDelta } from './activity-feed.js';
9
+ import { SLEEP_MS, actStableIndex, inSquareCount, latestIndexedActIndex, nowMs, rosterNames, sayNumberFor } from './runtime.js';
10
+ import { quoteShell } from './presentation.js';
11
+ import { enableRawMode, disableRawMode, enterAlternateScreen, leaveAlternateScreen, clearScreen, hideCursor, showCursor, renderStreamHeader, renderStreamEvent, renderWaiting, cursorUp, clearLine, } from './terminal.js';
12
+ const INITIAL_DUMP = 20;
13
+ export function streamNotificationFor(doc, item, recipient) {
14
+ return planActNotifications(doc, item).find((notification) => sameName(notification.recipient, recipient));
15
+ }
16
+ export function matchesStreamRecipient(doc, item, recipient) {
17
+ return streamNotificationFor(doc, item, recipient) !== undefined;
18
+ }
19
+ function renderDump(squarePath, doc, events, now) {
20
+ const relevant = events.filter((item) => item.act.kind !== 'read');
21
+ const active = inSquareCount(doc);
22
+ if (relevant.length === 0)
23
+ return `${renderStreamHeader(squarePath, rosterNames(doc).length, active)}\n\n (no activity yet)\n`;
24
+ const header = renderStreamHeader(squarePath, rosterNames(doc).length, active);
25
+ const body = relevant.map((item) => renderStreamEvent(item.act, now, item.act.kind === 'say' ? sayNumberFor(doc.acts, item.act) : undefined)).join('');
26
+ return `${header}\n${body}`;
27
+ }
28
+ export async function cmdStreamNdjson(squarePath, forName) {
29
+ if (!fs.existsSync(squarePath)) {
30
+ process.stderr.write(`square not found: ${squarePath}\n`);
31
+ process.exit(2);
32
+ }
33
+ let doc = loadSquare(squarePath);
34
+ let cursor = -1;
35
+ const emit = (events) => {
36
+ for (const { act, index } of events) {
37
+ const item = { act, index };
38
+ const notification = forName ? streamNotificationFor(doc, item, forName) : undefined;
39
+ if (forName && !notification)
40
+ continue;
41
+ process.stdout.write(`${JSON.stringify({
42
+ seq: index,
43
+ square: squarePath,
44
+ ...act,
45
+ ...(notification ? { via: notification.via } : {}),
46
+ })}\n`);
47
+ }
48
+ };
49
+ const backlog = indexedDelta(doc.acts, cursor);
50
+ emit(backlog);
51
+ cursor = latestIndexedActIndex(doc.acts.map((act) => ({ act, index: actStableIndex(act) })));
52
+ while (true) {
53
+ await sleep(SLEEP_MS);
54
+ try {
55
+ doc = loadSquare(squarePath);
56
+ }
57
+ catch {
58
+ // Transient read failure (e.g. concurrent writer mid-rename) — retry next poll.
59
+ continue;
60
+ }
61
+ const delta = indexedDelta(doc.acts, cursor);
62
+ if (delta.length === 0)
63
+ continue;
64
+ emit(delta);
65
+ cursor = latestIndexedActIndex(doc.acts.map((act) => ({ act, index: actStableIndex(act) })));
66
+ }
67
+ }
68
+ async function readKey() {
69
+ return new Promise((resolve) => {
70
+ const finish = (value) => {
71
+ clearTimeout(timer);
72
+ process.stdin.removeListener('data', onData);
73
+ resolve(value);
74
+ };
75
+ const onData = (chunk) => finish(chunk.toString());
76
+ const timer = setTimeout(() => finish(null), 100);
77
+ timer.unref();
78
+ process.stdin.once('data', onData);
79
+ });
80
+ }
81
+ export async function cmdStream(squarePath) {
82
+ if (!fs.existsSync(squarePath)) {
83
+ process.stderr.write(`square not found: ${squarePath}\n`);
84
+ process.exit(2);
85
+ }
86
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
87
+ process.stderr.write('✕ interactive stream requires a TTY\n');
88
+ process.stderr.write(`» square --square-path ${quoteShell(path.resolve(squarePath))} stream --ndjson\n`);
89
+ process.exit(2);
90
+ }
91
+ let doc = loadSquare(squarePath);
92
+ let cursor = latestIndexedActIndex(doc.acts.map((act) => ({ act, index: actStableIndex(act) })));
93
+ let stoppedBy;
94
+ const stop = (signal) => {
95
+ stoppedBy = signal;
96
+ };
97
+ const onSigint = () => stop('SIGINT');
98
+ const onSigterm = () => stop('SIGTERM');
99
+ process.once('SIGINT', onSigint);
100
+ process.once('SIGTERM', onSigterm);
101
+ enterAlternateScreen();
102
+ enableRawMode();
103
+ hideCursor();
104
+ clearScreen();
105
+ try {
106
+ const allIndexed = doc.acts.map((act) => ({ act, index: actStableIndex(act) }));
107
+ const initial = allIndexed.filter((item) => item.act.kind !== 'read').slice(-INITIAL_DUMP);
108
+ cursor = Math.max(cursor, latestIndexedActIndex(initial));
109
+ process.stdout.write(renderDump(squarePath, doc, initial, nowMs()));
110
+ process.stdout.write(renderWaiting());
111
+ while (stoppedBy === undefined) {
112
+ const key = await readKey();
113
+ if (key === 'q' || key === '\x1b' || key === '\x03')
114
+ break;
115
+ try {
116
+ doc = loadSquare(squarePath);
117
+ }
118
+ catch {
119
+ await sleep(SLEEP_MS);
120
+ continue;
121
+ }
122
+ const delta = indexedDelta(doc.acts, cursor);
123
+ if (delta.length === 0) {
124
+ await sleep(SLEEP_MS);
125
+ continue;
126
+ }
127
+ cursor = latestIndexedActIndex(doc.acts.map((act) => ({ act, index: actStableIndex(act) })));
128
+ cursorUp(1);
129
+ clearLine();
130
+ const fresh = nowMs();
131
+ for (const item of delta) {
132
+ if (item.act.kind !== 'read') {
133
+ process.stdout.write(renderStreamEvent(item.act, fresh, item.act.kind === 'say' ? sayNumberFor(doc.acts, item.act) : undefined));
134
+ }
135
+ }
136
+ process.stdout.write(renderWaiting());
137
+ }
138
+ }
139
+ finally {
140
+ process.off('SIGINT', onSigint);
141
+ process.off('SIGTERM', onSigterm);
142
+ disableRawMode();
143
+ showCursor();
144
+ leaveAlternateScreen();
145
+ showCursor();
146
+ }
147
+ if (stoppedBy !== undefined)
148
+ process.exitCode = stoppedBy === 'SIGINT' ? 130 : 143;
149
+ }
@@ -0,0 +1,125 @@
1
+ // terminal.ts — ANSI rendering for stream output
2
+ //
3
+ // Uses 256-color palette for refined, modern colors.
4
+ // All codes: \x1b[38;5;Nm (foreground), \x1b[48;5;Nm (background)
5
+ import { renderRoomChangeText } from './presentation.js';
6
+ import { formatRelativeTime } from './time.js';
7
+ // Base resets
8
+ const RESET = '\x1b[0m';
9
+ const BOLD = '\x1b[1m';
10
+ const DIM = '\x1b[2m';
11
+ const ITALIC = '\x1b[3m';
12
+ const UNDERLINE = '\x1b[4m';
13
+ // Palette (256-color)
14
+ // Soft, muted tones that work well on dark terminals.
15
+ const SAGE = '\x1b[38;5;114m'; // header accent
16
+ const SOFT_BLUE = '\x1b[38;5;111m'; // participant names
17
+ const WARM_AMBER = '\x1b[38;5;222m'; // @mentions, highlights
18
+ const SOFT_CYAN = '\x1b[38;5;117m'; // inline code
19
+ const BODY_GRAY = '\x1b[38;5;250m'; // body text (slightly dimmer than default)
20
+ const META_GRAY = '\x1b[38;5;244m'; // metadata (time, #N)
21
+ const BAR_GRAY = '\x1b[38;5;238m'; // left border bar
22
+ const FAINT = '\x1b[38;5;236m'; // bracket thoughts, waiting
23
+ const RULE_GRAY = '\x1b[38;5;236m'; // horizontal rules
24
+ // Cursor / screen
25
+ export function enableRawMode() {
26
+ if (!process.stdin.isTTY)
27
+ return;
28
+ if (typeof process.stdin.setRawMode === 'function')
29
+ process.stdin.setRawMode(true);
30
+ process.stdin.resume();
31
+ }
32
+ export function disableRawMode() {
33
+ if (!process.stdin.isTTY)
34
+ return;
35
+ if (typeof process.stdin.setRawMode === 'function')
36
+ process.stdin.setRawMode(false);
37
+ }
38
+ function writeControl(sequence) {
39
+ if (process.stdout.isTTY)
40
+ process.stdout.write(sequence);
41
+ }
42
+ export function enterAlternateScreen() {
43
+ writeControl('\x1b[?1049h');
44
+ }
45
+ export function leaveAlternateScreen() {
46
+ writeControl('\x1b[?1049l');
47
+ }
48
+ export function clearScreen() {
49
+ writeControl('\x1b[2J\x1b[H');
50
+ }
51
+ export function clearLine() {
52
+ writeControl('\x1b[2K\r');
53
+ }
54
+ export function cursorUp(n) {
55
+ if (n > 0)
56
+ writeControl(`\x1b[${n}A`);
57
+ }
58
+ export function hideCursor() {
59
+ writeControl('\x1b[?25l');
60
+ }
61
+ export function showCursor() {
62
+ writeControl('\x1b[?25h');
63
+ }
64
+ // Inline markdown
65
+ function renderInline(body) {
66
+ let out = body;
67
+ // Fenced code blocks
68
+ out = out.replace(/```(\w+)?\n?([\s\S]*?)```/g, (_, _lang, code) => {
69
+ const lines = code.trimEnd().split('\n');
70
+ return '\n' + lines.map((l) => `${BAR_GRAY} ${SOFT_CYAN}${l}${RESET}`).join('\n') + '\n';
71
+ });
72
+ // Inline code
73
+ out = out.replace(/`([^`]+)`/g, (_, code) => `${SOFT_CYAN}${code}${RESET}`);
74
+ // Bold
75
+ out = out.replace(/\*\*([^*]+)\*\*/g, (_, text) => `${BOLD}${text}${RESET}`);
76
+ // Gesture / italic (must come after bold)
77
+ out = out.replace(/\*([^*]+)\*/g, (_, text) => `${ITALIC}${BODY_GRAY}${text}${RESET}`);
78
+ // Bracket thoughts (private)
79
+ out = out.replace(/``\s*\[([^\]]*)\]\s*``/g, (_, thought) => `${FAINT}${ITALIC}[${thought}]${RESET}`);
80
+ // @mentions
81
+ out = out.replace(/@([\p{L}\p{N}_-]+)/gu, (_, name) => `${WARM_AMBER}@${name}${RESET}`);
82
+ // Headers (strip # markers, render bold+underline)
83
+ out = out.replace(/^#{1,3}\s+(.+)$/gm, (_, text) => `${BOLD}${UNDERLINE}${text}${RESET}`);
84
+ return out;
85
+ }
86
+ // Event renderer
87
+ export function renderStreamEvent(event, now, actNumber) {
88
+ switch (event.kind) {
89
+ case 'say': {
90
+ const name = `${SOFT_BLUE}${BOLD}${event.actor}${RESET}`;
91
+ const meta = `${META_GRAY}#${actNumber ?? 1} · ${formatRelativeTime(event.at, now)}${RESET}`;
92
+ const body = renderInline(event.body)
93
+ .split('\n')
94
+ .map((line) => `${BAR_GRAY}·${RESET} ${line}`)
95
+ .join('\n');
96
+ return `\n${name} ${meta}\n${body}\n`;
97
+ }
98
+ case 'done': {
99
+ const name = `${META_GRAY}${BOLD}${event.actor}${RESET}`;
100
+ const meta = `${META_GRAY}done · ${formatRelativeTime(event.at, now)}${RESET}`;
101
+ const body = event.body
102
+ ? `\n${renderInline(event.body).split('\n').map((line) => `${BAR_GRAY}·${RESET} ${line}`).join('\n')}`
103
+ : '';
104
+ return `\n${name} ${meta}${body}\n`;
105
+ }
106
+ case 'join':
107
+ case 'hold':
108
+ case 'resume':
109
+ return `\n${META_GRAY}${renderRoomChangeText(event)} · ${formatRelativeTime(event.at, now)}${RESET}\n`;
110
+ default:
111
+ return '';
112
+ }
113
+ }
114
+ // Header
115
+ export function renderStreamHeader(squarePath, participantCount, activeCount) {
116
+ const accent = `${SAGE}· the square${RESET}`;
117
+ const path = `${META_GRAY}${squarePath}${RESET}`;
118
+ const stats = `${META_GRAY}${participantCount} participants · ${activeCount} active${RESET}`;
119
+ const rule = `${RULE_GRAY}${'─'.repeat(60)}${RESET}`;
120
+ return `\n ${accent} · ${path}\n ${stats}\n ${rule}`;
121
+ }
122
+ // Waiting indicator
123
+ export function renderWaiting() {
124
+ return `\n${FAINT} ─── waiting ───${RESET}\n`;
125
+ }
package/dist/time.js ADDED
@@ -0,0 +1,81 @@
1
+ function pad(value, length = 2) {
2
+ return String(value).padStart(length, '0');
3
+ }
4
+ function formatOffset(date) {
5
+ const offsetMinutes = -date.getTimezoneOffset();
6
+ const sign = offsetMinutes >= 0 ? '+' : '-';
7
+ const abs = Math.abs(offsetMinutes);
8
+ return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
9
+ }
10
+ export function formatTimestamp(at) {
11
+ const date = new Date(at);
12
+ const time = date.getMilliseconds() !== 0
13
+ ? `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
14
+ : date.getSeconds() !== 0
15
+ ? `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
16
+ : `${pad(date.getHours())}:${pad(date.getMinutes())}`;
17
+ return [
18
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
19
+ time,
20
+ formatOffset(date),
21
+ ].join(' ');
22
+ }
23
+ export function formatDuration(ms) {
24
+ if (ms === undefined)
25
+ return 'a while';
26
+ if (ms < 1000)
27
+ return `${Math.max(0, ms)}ms`;
28
+ const seconds = Math.ceil(ms / 1000);
29
+ if (seconds < 60)
30
+ return `${seconds}s`;
31
+ const minutes = Math.floor(seconds / 60);
32
+ const remainingSeconds = seconds % 60;
33
+ if (minutes < 60)
34
+ return remainingSeconds === 0 ? `${minutes}m` : `${minutes}m ${remainingSeconds}s`;
35
+ const hours = Math.floor(minutes / 60);
36
+ const remainingMinutes = minutes % 60;
37
+ return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`;
38
+ }
39
+ export function formatRelativeTime(at, now) {
40
+ const diff = Math.max(0, (now ?? Date.now()) - at);
41
+ const seconds = Math.floor(diff / 1000);
42
+ if (seconds < 10)
43
+ return 'just now';
44
+ if (seconds < 60)
45
+ return `${seconds}s ago`;
46
+ const minutes = Math.floor(seconds / 60);
47
+ if (minutes < 60)
48
+ return `${minutes}m ago`;
49
+ const hours = Math.floor(minutes / 60);
50
+ if (hours < 24)
51
+ return `${hours}h ago`;
52
+ const days = Math.floor(hours / 24);
53
+ if (days < 30)
54
+ return `${days}d ago`;
55
+ return formatTimestamp(at);
56
+ }
57
+ export function parseTimestamp(value) {
58
+ const trimmed = value.trim();
59
+ const local = trimmed.match(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?) ([+-]\d{2}:\d{2})$/);
60
+ const normalized = local ? `${local[1]}T${local[2]}${local[3]}` : trimmed;
61
+ return Date.parse(normalized);
62
+ }
63
+ /** Relative offsets like -3d, -24h, -30m, -90s; absolute timestamps via parseTimestamp. */
64
+ export function parseTimeOrRelative(value, now = Date.now()) {
65
+ const trimmed = value.trim();
66
+ const relative = trimmed.match(/^(-?)(\d+)(ms|s|m|h|d)$/i);
67
+ if (relative) {
68
+ const sign = relative[1] === '-' ? -1 : 1;
69
+ const amount = Number(relative[2]);
70
+ const unit = relative[3].toLowerCase();
71
+ const multipliers = {
72
+ ms: 1,
73
+ s: 1000,
74
+ m: 60_000,
75
+ h: 3_600_000,
76
+ d: 86_400_000,
77
+ };
78
+ return now + sign * amount * multipliers[unit];
79
+ }
80
+ return parseTimestamp(trimmed);
81
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * wake-sink.ts — shared paseo wake dispatch.
3
+ *
4
+ * This is the single canonical implementation of "wake a paseo agent
5
+ * when they receive an undelivered @mention or --bell in a square."
6
+ *
7
+ * The detached one-shot notification worker uses this module after the
8
+ * natural-delivery grace period. The WakeSink is stateless; the worker owns
9
+ * receipt checks, timing, and failure isolation.
10
+ */
11
+ import { execSync, spawnSync } from 'node:child_process';
12
+ import { setTimeout as sleep } from 'node:timers/promises';
13
+ import { homedir } from 'node:os';
14
+ import { loadSquare } from './artifact.js';
15
+ import { SquareError } from './model.js';
16
+ import { leaseOwnsNotification } from './delivery.js';
17
+ import { sessionInbox } from './inbox.js';
18
+ import { waitForPaseoToolBoundary } from './paseo-timeline.js';
19
+ import { hasPresentedAttention, presentOnce } from './presented.js';
20
+ import { lookupParticipant } from './registry.js';
21
+ import { quoteShell } from './presentation.js';
22
+ import { isDeliveryDelivered, resolveRosterName } from './runtime.js';
23
+ async function waitForCatchDelivery(squarePath, recipient, notification, ownerId) {
24
+ const deadline = Date.now() + 180_000;
25
+ while (Date.now() < deadline) {
26
+ const doc = loadSquare(squarePath);
27
+ const known = resolveRosterName(doc, recipient) ?? recipient;
28
+ if (isDeliveryDelivered(doc, known, notification.item.index))
29
+ return true;
30
+ const lease = lookupParticipant(squarePath, known).find((binding) => binding.ownerId === ownerId);
31
+ const activeLease = lease === undefined
32
+ ? undefined
33
+ : sessionInbox(lease.sessionId).find((membership) => membership.name === known)?.catchLease;
34
+ if (activeLease === undefined || activeLease.expiresAt <= Date.now())
35
+ return false;
36
+ await sleep(Math.min(250, Math.max(1, activeLease.expiresAt - Date.now())));
37
+ }
38
+ return false;
39
+ }
40
+ const DEFAULT_PASEO_WAKE_TIMEOUT_MS = 90_000;
41
+ function paseoWakeTimeoutMs() {
42
+ const raw = process.env['SQUARE_PASEO_WAKE_TIMEOUT_MS'];
43
+ if (raw === undefined)
44
+ return DEFAULT_PASEO_WAKE_TIMEOUT_MS;
45
+ const value = Number.parseInt(raw, 10);
46
+ if (!Number.isFinite(value) || value <= 0) {
47
+ throw new SquareError('invalid_args', 'Invalid SQUARE_PASEO_WAKE_TIMEOUT_MS: expected a positive integer.');
48
+ }
49
+ return value;
50
+ }
51
+ // ── helpers ──────────────────────────────────────────────────────
52
+ function previewBody(body) {
53
+ return body.length > 200 ? body.slice(0, 197) + '...' : body;
54
+ }
55
+ function wakeNowCommand(squarePath, recipient) {
56
+ return `square --as ${quoteShell(recipient)} --square-path ${quoteShell(squarePath)} catch --now`;
57
+ }
58
+ function nativeWakePrompt(squarePath, recipient) {
59
+ return [
60
+ '<system-reminder source="square">',
61
+ `Square activity is waiting for @${recipient} in ${squarePath}.`,
62
+ 'The native adapter will present it at the next boundary. If it does not:',
63
+ `\`${wakeNowCommand(squarePath, recipient)}\``,
64
+ '</system-reminder>',
65
+ ].join('\n');
66
+ }
67
+ function fullWakePrompt(notification, squarePath) {
68
+ const { item, recipient } = notification;
69
+ const home = homedir();
70
+ const displayPath = squarePath.startsWith(home) ? `~${squarePath.slice(home.length)}` : squarePath;
71
+ return [
72
+ '<system-reminder source="square">',
73
+ `Mentioned by @${item.act.actor} in \`${displayPath}\``,
74
+ '',
75
+ `> ${previewBody(item.act.body).replace(/\n/g, '\n> ')}`,
76
+ '',
77
+ 'To catch up and keep presence current:',
78
+ `\`${wakeNowCommand(squarePath, recipient)}\``,
79
+ '',
80
+ '*(Async notification from Square.)*',
81
+ '</system-reminder>',
82
+ ].join('\n');
83
+ }
84
+ function hasNativeGuarantee(bindings, ownerId) {
85
+ return bindings.some((binding) => binding.ownerId === ownerId &&
86
+ (binding.channel === 'claude-code' ||
87
+ binding.channel === 'codex' ||
88
+ binding.channel === 'opencode' ||
89
+ binding.channel === 'pi'));
90
+ }
91
+ function sendPaseoPrompt(agentId, prompt) {
92
+ const result = spawnSync('paseo', ['send', agentId, '--prompt', prompt, '--no-wait'], {
93
+ stdio: 'ignore',
94
+ timeout: 5000,
95
+ });
96
+ if (result.error)
97
+ throw result.error;
98
+ if (result.status !== 0)
99
+ throw new Error(`paseo send exited with ${result.status ?? 'no status'}`);
100
+ }
101
+ // ── paseo agent discovery ────────────────────────────────────────
102
+ /** List all paseo agents. Returns empty array if paseo is unavailable. */
103
+ export function listPaseoAgents() {
104
+ try {
105
+ return JSON.parse(execSync('paseo ls --json', {
106
+ encoding: 'utf8',
107
+ stdio: ['ignore', 'pipe', 'ignore'],
108
+ }));
109
+ }
110
+ catch {
111
+ return [];
112
+ }
113
+ }
114
+ /**
115
+ * Wait for the tool calls that are running now to finish. Calls that start
116
+ * afterward are deliberately ignored: the transitional wake policy allows
117
+ * `paseo send` to replace the next call.
118
+ */
119
+ export async function waitForToolEnd(agentId, timeoutMs) {
120
+ return waitForPaseoToolBoundary(agentId, {
121
+ timeoutMs: timeoutMs ?? paseoWakeTimeoutMs(),
122
+ });
123
+ }
124
+ // ── paseo wake dispatch ──────────────────────────────────────────
125
+ /**
126
+ * Dispatch a paseo send wake prompt for a single notification.
127
+ *
128
+ * Resolves exact Paseo agent ids from the machine-local participant registry.
129
+ * Idle agents receive the wake immediately. Running agents wait for the
130
+ * current tool call boundary; the following `paseo send` may replace the next
131
+ * call by design.
132
+ */
133
+ export function selectPaseoWakeAgents(squarePath, recipient, agents = listPaseoAgents()) {
134
+ const ids = new Set(lookupParticipant(squarePath, recipient)
135
+ .map((binding) => binding.paseoAgentId)
136
+ .filter((id) => id !== undefined));
137
+ return agents.filter((agent) => ids.has(agent.id) && (agent.status === 'running' || agent.status === 'idle'));
138
+ }
139
+ export async function dispatchPaseoWake(notification, ctx) {
140
+ const { item, recipient } = notification;
141
+ const bindings = lookupParticipant(ctx.squarePath, recipient);
142
+ const activeAgents = selectPaseoWakeAgents(ctx.squarePath, recipient);
143
+ if (activeAgents.length === 0)
144
+ return;
145
+ const wokenOwners = new Set();
146
+ for (const active of activeAgents) {
147
+ if (active.status === 'running') {
148
+ const reachedBoundary = await waitForToolEnd(active.id);
149
+ if (!reachedBoundary)
150
+ continue;
151
+ }
152
+ // Re-read after the tool wait: harness inject may have presented during the wait.
153
+ try {
154
+ const doc = loadSquare(ctx.squarePath);
155
+ const known = resolveRosterName(doc, recipient) ?? recipient;
156
+ if (isDeliveryDelivered(doc, known, item.index))
157
+ return;
158
+ if (hasPresentedAttention(ctx.squarePath, known, item.index))
159
+ return;
160
+ }
161
+ catch {
162
+ // Receipt re-read failure fails open toward wake.
163
+ }
164
+ const binding = bindings.find((candidate) => candidate.paseoAgentId === active.id);
165
+ if (binding === undefined || wokenOwners.has(binding.ownerId))
166
+ continue;
167
+ try {
168
+ const catchMembership = sessionInbox(binding.sessionId).find((membership) => membership.name === (resolveRosterName(loadSquare(ctx.squarePath), recipient) ?? recipient) &&
169
+ membership.catchLease !== undefined &&
170
+ leaseOwnsNotification(membership.catchLease, {
171
+ actor: item.act.actor,
172
+ body: item.act.body,
173
+ via: notification.via === 'bell' ? 'bell' : 'mention',
174
+ }));
175
+ if (catchMembership?.catchLease !== undefined) {
176
+ if (await waitForCatchDelivery(ctx.squarePath, recipient, notification, binding.ownerId))
177
+ return;
178
+ const current = lookupParticipant(ctx.squarePath, recipient).find((candidate) => candidate.paseoAgentId === active.id);
179
+ if (current?.ownerId !== binding.ownerId)
180
+ continue;
181
+ const doc = loadSquare(ctx.squarePath);
182
+ const known = resolveRosterName(doc, recipient) ?? recipient;
183
+ if (isDeliveryDelivered(doc, known, item.index) ||
184
+ hasPresentedAttention(ctx.squarePath, known, item.index)) {
185
+ return;
186
+ }
187
+ }
188
+ if (hasNativeGuarantee(bindings, binding.ownerId)) {
189
+ sendPaseoPrompt(active.id, nativeWakePrompt(ctx.squarePath, recipient));
190
+ wokenOwners.add(binding.ownerId);
191
+ continue;
192
+ }
193
+ presentOnce(binding.sessionId, (sessionId) => sessionInbox(sessionId)
194
+ .map((membership) => ({
195
+ ...membership,
196
+ notifications: membership.notifications.filter((candidate) => candidate.actIndex === item.index),
197
+ }))
198
+ .filter((membership) => membership.notifications.length > 0), () => sendPaseoPrompt(active.id, fullWakePrompt(notification, ctx.squarePath)));
199
+ wokenOwners.add(binding.ownerId);
200
+ }
201
+ catch {
202
+ // Paseo unavailable: notification remains unpresented for a later retry.
203
+ }
204
+ }
205
+ }
206
+ // ── WakeSink factory ─────────────────────────────────────────────
207
+ /** Create the default paseo WakeSink used by both sync and async paths. */
208
+ export function paseoWakeSink() {
209
+ return {
210
+ name: 'paseo',
211
+ dispatch: dispatchPaseoWake,
212
+ };
213
+ }
214
+ /** Default sink list, respecting SQUARE_DISABLE_PASEO_WAKE. */
215
+ export function defaultWakeSinks() {
216
+ if (process.env.SQUARE_DISABLE_PASEO_WAKE === '1')
217
+ return [];
218
+ return [paseoWakeSink()];
219
+ }