@astrosheep/square 0.3.5 → 0.3.7

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 (57) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +3 -2
  2. package/codex-plugin/hooks/hooks.json +3 -14
  3. package/dist/activity-feed.js +26 -18
  4. package/dist/activity.js +10 -10
  5. package/dist/artifact.js +138 -203
  6. package/dist/boundary-presentation.js +77 -0
  7. package/dist/claude-hook.js +4 -94
  8. package/dist/cli/context.js +7 -7
  9. package/dist/cli/maintenance-commands.js +10 -26
  10. package/dist/cli/meta-commands.js +3 -6
  11. package/dist/cli/observation-commands.js +48 -53
  12. package/dist/cli/program.js +4 -4
  13. package/dist/cli/registry.js +5 -5
  14. package/dist/cli/square-commands.js +27 -20
  15. package/dist/cmd/notify-once.js +23 -21
  16. package/dist/codex-hook.js +22 -0
  17. package/dist/compact.js +1 -1
  18. package/dist/decisions.js +61 -88
  19. package/dist/delivery-health.js +104 -210
  20. package/dist/delivery.js +68 -18
  21. package/dist/doctor.js +9 -8
  22. package/dist/harness-claude.js +38 -245
  23. package/dist/harness-codex.js +82 -616
  24. package/dist/harness-stage.js +36 -0
  25. package/dist/harness.js +3 -5
  26. package/dist/help.js +43 -35
  27. package/dist/inbox.js +12 -11
  28. package/dist/index.js +10 -121
  29. package/dist/list.js +1 -1
  30. package/dist/model.js +0 -6
  31. package/dist/notification-failures.js +54 -0
  32. package/dist/notifications.js +47 -62
  33. package/dist/paseo-delivery.js +160 -0
  34. package/dist/paseo-state.js +31 -0
  35. package/dist/paseo-timeline.js +58 -188
  36. package/dist/presentation.js +57 -64
  37. package/dist/presented.js +9 -8
  38. package/dist/registry.js +55 -45
  39. package/dist/runtime.js +27 -84
  40. package/dist/square-application.js +135 -130
  41. package/dist/square-core.js +3 -11
  42. package/dist/stream.js +27 -126
  43. package/dist/wake-sink.js +3 -214
  44. package/dist/watch.js +65 -122
  45. package/extensions/square-opencode.js +8 -73
  46. package/extensions/square-pi.js +8 -132
  47. package/guides/architect.md +3 -3
  48. package/guides/participant.md +25 -16
  49. package/package.json +2 -2
  50. package/skills/brainstorm/SKILL.md +25 -32
  51. package/skills/square/.claude-plugin/plugin.json +1 -1
  52. package/skills/square/SKILL.md +39 -107
  53. package/skills/square/hooks/hooks.json +2 -13
  54. package/skills/square-feedback/SKILL.md +4 -4
  55. package/dist/harness-lifecycle.js +0 -102
  56. package/dist/square-store.js +0 -111
  57. package/dist/terminal.js +0 -125
@@ -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 './paseo-delivery.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
  }
@@ -0,0 +1,160 @@
1
+ import { homedir } from 'node:os';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import { loadSquare } from './artifact.js';
4
+ import { isDeliveryDelivered, leaseOwnsNotification, } from './delivery.js';
5
+ import { sessionInbox } from './inbox.js';
6
+ import { hasPresentedForOwner, presentOnce } from './presented.js';
7
+ import { lookupParticipant } from './registry.js';
8
+ import { quoteShell } from './presentation.js';
9
+ import { isCurrentlyJoined, resolveRosterName } from './runtime.js';
10
+ import { discoverPaseoAgents, waitForPaseoWakeBoundary, } from './paseo-state.js';
11
+ import { sendPaseoWake } from './wake-sink.js';
12
+ export class PaseoWakeError extends Error {
13
+ diagnostic;
14
+ constructor(message, diagnostic) {
15
+ super(message);
16
+ this.diagnostic = diagnostic;
17
+ this.name = 'PaseoWakeError';
18
+ }
19
+ }
20
+ function endpoint() {
21
+ return process.env.SQUARE_PASEO_WS_URL ?? process.env.PASEO_LISTEN ?? '127.0.0.1:6767';
22
+ }
23
+ function diagnostic(phase, ownership, code) {
24
+ return {
25
+ phase,
26
+ code,
27
+ command: phase === 'discovery' ? 'paseo ls --json' : 'paseo send <agent-id> --prompt <prompt> --no-wait',
28
+ endpoint: endpoint(),
29
+ paseoAgentIds: ownership.map((item) => item.agentId),
30
+ ownerIds: [...new Set(ownership.map((item) => item.ownerId))],
31
+ passwordPresent: Boolean(process.env.PASEO_PASSWORD),
32
+ };
33
+ }
34
+ function native(binding) {
35
+ return ['claude-code', 'codex', 'opencode', 'pi'].includes(binding.channel);
36
+ }
37
+ function ownershipSnapshot(bindings) {
38
+ const out = new Map();
39
+ for (const binding of bindings) {
40
+ if (!binding.paseoAgentId)
41
+ continue;
42
+ const owner = bindings.filter((item) => item.ownerId === binding.ownerId);
43
+ out.set(`${binding.ownerId}\0${binding.paseoAgentId}`, {
44
+ agentId: binding.paseoAgentId,
45
+ ownerId: binding.ownerId,
46
+ sessionId: owner.find(native)?.sessionId ?? binding.sessionId,
47
+ nativeGuarantee: owner.some(native),
48
+ });
49
+ }
50
+ return [...out.values()];
51
+ }
52
+ function selectActiveAgents(ownership, agents) {
53
+ const ids = new Set(ownership.map((item) => item.agentId));
54
+ return agents.filter((agent) => ids.has(agent.id) && (agent.status === 'idle' || agent.status === 'running'));
55
+ }
56
+ function catchCommand(squarePath, recipient) {
57
+ return `square --as ${quoteShell(recipient)} --square-path ${quoteShell(squarePath)} catch --now`;
58
+ }
59
+ function prompt(notification, squarePath, nativeWake) {
60
+ const display = squarePath.startsWith(homedir()) ? `~${squarePath.slice(homedir().length)}` : squarePath;
61
+ const body = notification.item.body.length > 200 ? `${notification.item.body.slice(0, 197)}...` : notification.item.body;
62
+ return [
63
+ '<system-reminder source="square">',
64
+ `${notification.route === 'bell' ? 'Bell' : notification.route === 'beside' ? 'Beside' : 'Mention'} from @${notification.item.actor} in \`${display}\``,
65
+ nativeWake ? 'The native adapter will present it at the next boundary.' : `> ${body.replace(/\n/g, '\n> ')}`,
66
+ `\`${catchCommand(squarePath, notification.recipient)}\``,
67
+ '</system-reminder>',
68
+ ].join('\n');
69
+ }
70
+ async function waitForCatch(squarePath, recipient, actIndex, ownerId) {
71
+ const deadline = Date.now() + 180_000;
72
+ while (Date.now() < deadline) {
73
+ const doc = loadSquare(squarePath);
74
+ if (isDeliveryDelivered(doc, recipient, actIndex))
75
+ return true;
76
+ const binding = lookupParticipant(squarePath, recipient).find((item) => item.ownerId === ownerId);
77
+ const lease = binding && sessionInbox(binding.sessionId).find((item) => item.name === recipient)?.catchLease;
78
+ if (!lease || lease.expiresAt <= Date.now())
79
+ return false;
80
+ await sleep(Math.min(250, lease.expiresAt - Date.now()));
81
+ }
82
+ return false;
83
+ }
84
+ function send(request, ownership) {
85
+ try {
86
+ sendPaseoWake(request);
87
+ }
88
+ catch (error) {
89
+ throw new PaseoWakeError(error instanceof Error ? error.message : String(error), diagnostic('send', ownership, 'failed'));
90
+ }
91
+ }
92
+ export async function dispatchPaseoNotification(notification, ctx) {
93
+ const initial = loadSquare(ctx.squarePath);
94
+ const recipient = resolveRosterName(initial, notification.recipient);
95
+ if (!recipient || !isCurrentlyJoined(initial.acts, recipient))
96
+ return;
97
+ const ownership = ownershipSnapshot(lookupParticipant(ctx.squarePath, recipient));
98
+ if (ownership.length === 0)
99
+ return;
100
+ const discovery = discoverPaseoAgents();
101
+ if (discovery.error && discovery.agents.length === 0) {
102
+ throw new PaseoWakeError(`Paseo unavailable: ${discovery.error}`, diagnostic('discovery', ownership, 'unavailable'));
103
+ }
104
+ const active = selectActiveAgents(ownership, discovery.agents);
105
+ if (active.length === 0) {
106
+ throw new PaseoWakeError('No registered Paseo agent is idle or running.', diagnostic('selection', ownership, 'not_active'));
107
+ }
108
+ let boundaryTimedOut = false;
109
+ for (const agent of active) {
110
+ const owner = ownership.find((item) => item.agentId === agent.id);
111
+ if (!owner || hasPresentedForOwner(owner.ownerId, ctx.squarePath, recipient, notification.item.index))
112
+ continue;
113
+ if (!(await waitForPaseoWakeBoundary(agent))) {
114
+ boundaryTimedOut = true;
115
+ continue;
116
+ }
117
+ const latest = loadSquare(ctx.squarePath);
118
+ if (!isCurrentlyJoined(latest.acts, recipient) || isDeliveryDelivered(latest, recipient, notification.item.index))
119
+ return;
120
+ const current = lookupParticipant(ctx.squarePath, recipient).find((item) => item.ownerId === owner.ownerId && item.paseoAgentId === owner.agentId);
121
+ if (!current)
122
+ continue;
123
+ const activeCatch = sessionInbox(current.sessionId).find((item) => item.name === recipient)?.catchLease;
124
+ if (activeCatch &&
125
+ leaseOwnsNotification(activeCatch, {
126
+ actor: notification.item.actor,
127
+ body: notification.item.body,
128
+ route: notification.route,
129
+ recipient,
130
+ }) &&
131
+ (await waitForCatch(ctx.squarePath, recipient, notification.item.index, owner.ownerId))) {
132
+ return;
133
+ }
134
+ const nativeWake = owner.nativeGuarantee;
135
+ const request = {
136
+ agentId: agent.id,
137
+ prompt: prompt({ ...notification, recipient }, ctx.squarePath, nativeWake),
138
+ };
139
+ if (nativeWake) {
140
+ send(request, ownership);
141
+ return;
142
+ }
143
+ presentOnce(current.sessionId, (id) => sessionInbox(id)
144
+ .map((item) => ({ ...item, notifications: item.notifications.filter((note) => note.actIndex === notification.item.index) }))
145
+ .filter((item) => item.notifications.length > 0), () => {
146
+ send(request, ownership);
147
+ return true;
148
+ });
149
+ return;
150
+ }
151
+ if (boundaryTimedOut) {
152
+ throw new PaseoWakeError('Paseo did not reach the current tool boundary before the wake timeout.', diagnostic('boundary', ownership, 'timeout'));
153
+ }
154
+ }
155
+ export function paseoWakeSink() {
156
+ return { name: 'paseo', dispatch: dispatchPaseoNotification };
157
+ }
158
+ export function defaultWakeSinks() {
159
+ return process.env.SQUARE_DISABLE_PASEO_WAKE === '1' ? [] : [paseoWakeSink()];
160
+ }
@@ -0,0 +1,31 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { waitForPaseoToolBoundary } from './paseo-timeline.js';
3
+ export function discoverPaseoAgents(timeoutMs = 5000) {
4
+ try {
5
+ const raw = execFileSync(process.env.SQUARE_PASEO_BIN || 'paseo', ['ls', '--json'], {
6
+ encoding: 'utf8',
7
+ timeout: timeoutMs,
8
+ stdio: ['ignore', 'pipe', 'pipe'],
9
+ });
10
+ const parsed = JSON.parse(raw);
11
+ const agents = Array.isArray(parsed) ? parsed : parsed?.agents;
12
+ if (!Array.isArray(agents))
13
+ return { agents: [], error: 'Paseo returned malformed agent inventory.' };
14
+ return {
15
+ agents: agents.filter((item) => item !== null &&
16
+ typeof item === 'object' &&
17
+ typeof item.id === 'string' &&
18
+ typeof item.status === 'string'),
19
+ };
20
+ }
21
+ catch (error) {
22
+ return { agents: [], error: error instanceof Error ? error.message : String(error) };
23
+ }
24
+ }
25
+ export async function waitForPaseoWakeBoundary(agent) {
26
+ if (agent.status === 'idle')
27
+ return true;
28
+ if (agent.status !== 'running')
29
+ return false;
30
+ return waitForPaseoToolBoundary(agent.id);
31
+ }
@@ -1,206 +1,76 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { setTimeout as sleep } from 'node:timers/promises';
3
- const DEFAULT_POLL_INTERVAL_MS = 100;
4
- const DEFAULT_REQUEST_TIMEOUT_MS = 3000;
5
- function paseoWebSocketUrl() {
6
- const override = process.env['SQUARE_PASEO_WS_URL']?.trim();
7
- if (override)
8
- return override;
9
- const listen = process.env['PASEO_LISTEN']?.trim();
10
- if (!listen)
11
- return 'ws://127.0.0.1:6767/ws';
12
- if (/^wss?:\/\//i.test(listen)) {
13
- const url = new URL(listen);
14
- if (url.pathname === '/' || url.pathname === '')
15
- url.pathname = '/ws';
16
- return url.toString();
17
- }
18
- if (/^tcp:\/\//i.test(listen)) {
19
- const url = new URL(listen);
20
- const secure = url.searchParams.get('ssl') === 'true';
21
- url.protocol = secure ? 'wss:' : 'ws:';
22
- url.pathname = '/ws';
23
- return url.toString();
24
- }
25
- if (/^\d+$/.test(listen))
26
- return `ws://127.0.0.1:${listen}/ws`;
27
- return `ws://${listen.replace(/\/$/, '')}/ws`;
28
- }
29
- function parseSnapshot(payload) {
30
- if (payload === null || typeof payload !== 'object') {
31
- throw new Error('Invalid Paseo timeline response.');
32
- }
33
- const response = payload;
34
- if (typeof response.error === 'string' && response.error) {
35
- throw new Error(response.error);
36
- }
37
- const latestTools = new Map();
38
- for (const entry of response.entries ?? []) {
39
- const item = entry.item;
40
- if (item?.['type'] !== 'tool_call' ||
41
- typeof item['callId'] !== 'string' ||
42
- (item['status'] !== 'running' && item['status'] !== 'completed' && item['status'] !== 'failed')) {
43
- continue;
44
- }
45
- latestTools.set(item['callId'], item['status']);
46
- }
47
- return {
48
- agentStatus: typeof response.agent?.status === 'string' ? response.agent.status : 'unknown',
49
- toolCalls: Array.from(latestTools, ([callId, status]) => ({ callId, status })),
50
- };
51
- }
52
- class PaseoTimelineProbe {
53
- socket;
54
- pending = new Map();
55
- constructor(socket) {
56
- this.socket = socket;
57
- socket.addEventListener('message', (event) => {
58
- let envelope;
59
- try {
60
- envelope = JSON.parse(String(event.data));
61
- }
62
- catch {
63
- return;
64
- }
65
- if (envelope === null || typeof envelope !== 'object')
66
- return;
67
- const outer = envelope;
68
- if (outer.type !== 'session' || outer.message === null || typeof outer.message !== 'object') {
69
- return;
70
- }
71
- const message = outer.message;
72
- if (message.type !== 'fetch_agent_timeline_response' ||
73
- message.payload === null ||
74
- typeof message.payload !== 'object') {
75
- return;
76
- }
77
- const requestId = message.payload.requestId;
78
- if (typeof requestId !== 'string')
79
- return;
80
- const request = this.pending.get(requestId);
81
- if (!request)
82
- return;
83
- clearTimeout(request.timer);
84
- this.pending.delete(requestId);
85
- try {
86
- request.resolve(parseSnapshot(message.payload));
87
- }
88
- catch (error) {
89
- request.reject(error instanceof Error ? error : new Error(String(error)));
90
- }
91
- });
92
- const rejectPending = () => {
93
- for (const [requestId, request] of this.pending) {
94
- clearTimeout(request.timer);
95
- request.reject(new Error('Paseo timeline connection closed.'));
96
- this.pending.delete(requestId);
97
- }
98
- };
99
- socket.addEventListener('close', rejectPending);
100
- socket.addEventListener('error', rejectPending);
101
- }
102
- static async connect(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
103
- const socket = new WebSocket(paseoWebSocketUrl());
104
- await new Promise((resolvePromise, reject) => {
105
- const timer = setTimeout(() => {
106
- socket.close();
107
- reject(new Error('Timed out connecting to Paseo.'));
108
- }, timeoutMs);
109
- socket.addEventListener('open', () => {
110
- clearTimeout(timer);
111
- resolvePromise();
112
- }, { once: true });
113
- socket.addEventListener('error', () => {
114
- clearTimeout(timer);
115
- reject(new Error('Could not connect to Paseo.'));
116
- }, { once: true });
117
- });
118
- const probe = new PaseoTimelineProbe(socket);
119
- socket.send(JSON.stringify({
120
- type: 'hello',
121
- clientId: `square-wake-${process.pid}-${randomUUID()}`,
122
- clientType: 'cli',
123
- protocolVersion: 1,
124
- }));
125
- return probe;
126
- }
127
- snapshot(agentId, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
128
- const requestId = randomUUID();
129
- return new Promise((resolvePromise, reject) => {
130
- const timer = setTimeout(() => {
131
- this.pending.delete(requestId);
132
- reject(new Error('Timed out reading Paseo timeline.'));
133
- }, timeoutMs);
134
- this.pending.set(requestId, { resolve: resolvePromise, reject, timer });
135
- this.socket.send(JSON.stringify({
136
- type: 'session',
137
- message: {
138
- type: 'fetch_agent_timeline_request',
139
- agentId,
140
- requestId,
141
- direction: 'tail',
142
- limit: 200,
143
- projection: 'projected',
144
- },
145
- }));
146
- });
147
- }
148
- close() {
149
- this.socket.close();
150
- }
151
- }
152
- async function waitWithSnapshots(agentId, readSnapshot, options) {
153
- const initial = await readSnapshot(agentId);
2
+ async function waitSnapshots(agentId, read, opts) {
3
+ const initial = await read(agentId);
154
4
  if (initial.agentStatus === 'idle')
155
5
  return true;
156
6
  if (initial.agentStatus !== 'running')
157
7
  return false;
158
- const currentCalls = new Set(initial.toolCalls.filter((tool) => tool.status === 'running').map((tool) => tool.callId));
159
- if (currentCalls.size === 0)
8
+ const running = new Set(initial.toolCalls.filter((tool) => tool.status === 'running').map((tool) => tool.callId));
9
+ if (running.size === 0)
160
10
  return true;
161
- const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
162
- const delay = options.delay ?? ((ms) => sleep(ms));
163
- const deadline = options.timeoutMs === undefined ? null : Date.now() + options.timeoutMs;
164
- while (deadline === null || Date.now() < deadline) {
165
- await delay(pollIntervalMs);
166
- const snapshot = await readSnapshot(agentId);
167
- if (snapshot.agentStatus === 'idle')
11
+ const delay = opts.delay ?? ((ms) => sleep(ms));
12
+ const interval = opts.pollIntervalMs ?? 100;
13
+ const deadline = Date.now() + (opts.timeoutMs ?? 30_000);
14
+ while (Date.now() < deadline) {
15
+ await delay(interval);
16
+ const current = await read(agentId);
17
+ if (current.agentStatus === 'idle')
168
18
  return true;
169
- if (snapshot.agentStatus !== 'running')
19
+ if (current.agentStatus !== 'running')
170
20
  return false;
171
- const latest = new Map(snapshot.toolCalls.map((tool) => [tool.callId, tool.status]));
172
- const allTerminal = Array.from(currentCalls).every((callId) => {
173
- const status = latest.get(callId);
174
- return status === 'completed' || status === 'failed';
175
- });
176
- if (allTerminal)
21
+ const states = new Map(current.toolCalls.map((tool) => [tool.callId, tool.status]));
22
+ if ([...running].every((id) => states.get(id) === 'completed' || states.get(id) === 'failed'))
177
23
  return true;
178
24
  }
179
25
  return false;
180
26
  }
181
- /**
182
- * Wait until the tool calls that were running at the initial snapshot finish.
183
- * Tool calls that start later are intentionally ignored: the subsequent
184
- * `paseo send` may replace the next tool call under the transitional policy.
185
- */
186
- export async function waitForPaseoToolBoundary(agentId, options = {}) {
187
- if (options.readSnapshot) {
188
- try {
189
- return await waitWithSnapshots(agentId, options.readSnapshot, options);
190
- }
191
- catch {
192
- return false;
193
- }
194
- }
195
- let probe = null;
27
+ function paseoUrl() {
28
+ const value = process.env.SQUARE_PASEO_WS_URL?.trim() || process.env.PASEO_LISTEN?.trim();
29
+ if (!value)
30
+ return 'ws://127.0.0.1:6767/ws';
31
+ if (/^wss?:\/\//i.test(value))
32
+ return value.replace(/\/$/, '') + (value.endsWith('/ws') ? '' : '/ws');
33
+ if (/^\d+$/.test(value))
34
+ return `ws://127.0.0.1:${value}/ws`;
35
+ return `ws://${value.replace(/\/$/, '')}/ws`;
36
+ }
37
+ async function remoteSnapshot(agentId) {
38
+ const socket = new WebSocket(paseoUrl());
39
+ await new Promise((resolve, reject) => {
40
+ const timer = setTimeout(() => { socket.close(); reject(new Error('Paseo timeline connection timed out.')); }, 3000);
41
+ socket.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
42
+ socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('Paseo timeline unavailable.')); }, { once: true });
43
+ });
44
+ return await new Promise((resolve, reject) => {
45
+ const timer = setTimeout(() => { socket.close(); reject(new Error('Paseo timeline request timed out.')); }, 3000);
46
+ const requestId = `${process.pid}-${Date.now()}`;
47
+ socket.addEventListener('message', (event) => {
48
+ try {
49
+ const outer = JSON.parse(String(event.data));
50
+ const payload = outer?.message?.payload;
51
+ if (outer?.message?.type !== 'fetch_agent_timeline_response' || payload?.requestId !== requestId)
52
+ return;
53
+ clearTimeout(timer);
54
+ socket.close();
55
+ const tools = new Map();
56
+ for (const entry of payload.entries ?? []) {
57
+ const item = entry?.item;
58
+ if (item?.type === 'tool_call' && typeof item.callId === 'string' && ['running', 'completed', 'failed'].includes(item.status))
59
+ tools.set(item.callId, item.status);
60
+ }
61
+ resolve({ agentStatus: typeof payload.agent?.status === 'string' ? payload.agent.status : 'unknown', toolCalls: [...tools].map(([callId, status]) => ({ callId, status })) });
62
+ }
63
+ catch { /* ignore unrelated frames */ }
64
+ });
65
+ socket.send(JSON.stringify({ type: 'hello', clientId: `square-${process.pid}`, clientType: 'cli', protocolVersion: 1 }));
66
+ socket.send(JSON.stringify({ type: 'session', message: { type: 'fetch_agent_timeline_request', agentId, requestId, direction: 'tail', limit: 200, projection: 'projected' } }));
67
+ });
68
+ }
69
+ export async function waitForPaseoToolBoundary(agentId, opts = {}) {
196
70
  try {
197
- probe = await PaseoTimelineProbe.connect();
198
- return await waitWithSnapshots(agentId, (id) => probe.snapshot(id), options);
71
+ return await waitSnapshots(agentId, opts.readSnapshot ?? remoteSnapshot, opts);
199
72
  }
200
73
  catch {
201
74
  return false;
202
75
  }
203
- finally {
204
- probe?.close();
205
- }
206
76
  }