@astrosheep/square 0.3.9 → 0.3.11

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 (43) hide show
  1. package/codex-plugin/.codex-plugin/plugin.json +1 -1
  2. package/dist/activity.js +4 -0
  3. package/dist/artifact.js +33 -2
  4. package/dist/cli/context.js +1 -1
  5. package/dist/cli/maintenance-commands.js +12 -1
  6. package/dist/cli/observation-commands.js +3 -16
  7. package/dist/cli/program.js +0 -4
  8. package/dist/cli/square-commands.js +23 -3
  9. package/dist/cmd/notify-once.js +5 -15
  10. package/dist/decisions.js +10 -2
  11. package/dist/delivery-health.js +55 -136
  12. package/dist/doctor.js +1 -0
  13. package/dist/file-lock.js +112 -0
  14. package/dist/harness-codex.js +35 -29
  15. package/dist/harness-links.js +0 -3
  16. package/dist/harness-pi.js +57 -0
  17. package/dist/harness.js +10 -15
  18. package/dist/help.js +8 -8
  19. package/dist/index.js +5 -1
  20. package/dist/model.js +4 -0
  21. package/dist/notifications.js +205 -28
  22. package/dist/paseo-connection.js +135 -0
  23. package/dist/paseo-delivery.js +73 -144
  24. package/dist/paseo-state.js +1 -1
  25. package/dist/paseo-timeline.js +32 -42
  26. package/dist/presentation.js +2 -2
  27. package/dist/presented.js +10 -72
  28. package/dist/registry.js +23 -24
  29. package/dist/routes.js +153 -0
  30. package/dist/square-application.js +47 -49
  31. package/dist/stream.js +1 -1
  32. package/dist/wake-attempts.js +171 -0
  33. package/dist/wake-evidence.js +35 -0
  34. package/dist/wake-port.js +22 -0
  35. package/dist/wake-sink.js +45 -6
  36. package/dist/watch.js +1 -2
  37. package/guides/participant.md +1 -1
  38. package/package.json +6 -1
  39. package/skills/brainstorm/SKILL.md +24 -24
  40. package/skills/square/.claude-plugin/plugin.json +1 -1
  41. package/skills/square/SKILL.md +4 -3
  42. package/skills/square-feedback/SKILL.md +2 -2
  43. package/dist/notification-failures.js +0 -54
@@ -1,4 +1,5 @@
1
1
  import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { connectPaseoDaemon } from './paseo-connection.js';
2
3
  async function waitSnapshots(agentId, read, opts) {
3
4
  const initial = await read(agentId);
4
5
  if (initial.agentStatus === 'idle')
@@ -24,53 +25,42 @@ async function waitSnapshots(agentId, read, opts) {
24
25
  }
25
26
  return false;
26
27
  }
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
- });
28
+ function snapshotFromPayload(payload) {
29
+ const tools = new Map();
30
+ for (const entry of payload.entries ?? []) {
31
+ const item = entry.item;
32
+ if (item.type === 'tool_call' && ['running', 'completed', 'failed'].includes(item.status)) {
33
+ tools.set(item.callId, item.status);
34
+ }
35
+ }
36
+ return {
37
+ agentStatus: payload.agent?.status ?? 'unknown',
38
+ toolCalls: [...tools].map(([callId, status]) => ({ callId, status })),
39
+ };
68
40
  }
69
41
  export async function waitForPaseoToolBoundary(agentId, opts = {}) {
42
+ if (opts.readSnapshot !== undefined) {
43
+ try {
44
+ return await waitSnapshots(agentId, opts.readSnapshot, opts);
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ let client;
70
51
  try {
71
- return await waitSnapshots(agentId, opts.readSnapshot ?? remoteSnapshot, opts);
52
+ client = await connectPaseoDaemon();
53
+ return await waitSnapshots(agentId, async (id) => snapshotFromPayload(await client.fetchAgentTimeline(id, {
54
+ direction: 'tail',
55
+ limit: 200,
56
+ projection: 'projected',
57
+ timeout: 3_000,
58
+ })), opts);
72
59
  }
73
60
  catch {
74
61
  return false;
75
62
  }
63
+ finally {
64
+ await client?.close().catch(() => { });
65
+ }
76
66
  }
@@ -31,10 +31,10 @@ export function quoteShell(value) {
31
31
  return `'${value.replace(/'/g, `'\\''`)}'`;
32
32
  }
33
33
  export function commandPrefix(squarePath) {
34
- return `square --square-path ${quoteShell(squarePath)}`;
34
+ return `square --location ${quoteShell(squarePath)}`;
35
35
  }
36
36
  export function participantCommandPrefix(squarePath, name) {
37
- return `square --square-path ${quoteShell(path.resolve(squarePath))} --as ${quoteShell(name)}`;
37
+ return `square --location ${quoteShell(path.resolve(squarePath))} --as ${quoteShell(name)}`;
38
38
  }
39
39
  function formatAge(ms) {
40
40
  if (ms === undefined)
package/dist/presented.js CHANGED
@@ -2,13 +2,12 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
+ import { withFileLockSync } from './file-lock.js';
5
6
  import { canonicalSquarePath, lookupParticipant, lookupSessionBindings } from './registry.js';
6
7
  import { sameName } from './model.js';
7
8
  const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
8
9
  const LOCK_STALE_MS = 5 * 60_000;
9
10
  const LOCK_RETRY_MS = 10;
10
- const lockWait = new Int32Array(new SharedArrayBuffer(4));
11
- const heldLocks = new Set();
12
11
  export function presentedPath(env = process.env) {
13
12
  return env.SQUARE_PRESENTED || path.join(os.homedir(), '.square', 'presented.ndjsonl');
14
13
  }
@@ -33,7 +32,7 @@ function readRows(filePath, now = Date.now()) {
33
32
  try {
34
33
  const parsed = JSON.parse(line);
35
34
  if (parsed.v !== 2 ||
36
- typeof parsed.ts !== 'number' ||
35
+ typeof parsed.ts !== 'number' || !Number.isFinite(parsed.ts) ||
37
36
  typeof parsed.owner_id !== 'string' ||
38
37
  typeof parsed.square_path !== 'string' ||
39
38
  typeof parsed.name !== 'string' ||
@@ -57,67 +56,6 @@ function writeRows(filePath, rows) {
57
56
  });
58
57
  fs.renameSync(temp, filePath);
59
58
  }
60
- function lockOwnerState(lockPath) {
61
- let pid;
62
- try {
63
- pid = Number.parseInt(fs.readFileSync(lockPath, 'utf8').split('\n')[0], 10);
64
- }
65
- catch {
66
- return 'unknown';
67
- }
68
- if (!Number.isSafeInteger(pid) || pid <= 0)
69
- return 'unknown';
70
- try {
71
- process.kill(pid, 0);
72
- return 'alive';
73
- }
74
- catch (error) {
75
- return error.code === 'ESRCH' ? 'dead' : 'alive';
76
- }
77
- }
78
- function withFileLock(lockPath, fn) {
79
- if (heldLocks.has(lockPath))
80
- throw new Error(`Reentrant presented lock: ${lockPath}`);
81
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
82
- while (true) {
83
- let acquired = false;
84
- try {
85
- const fd = fs.openSync(lockPath, 'wx', 0o600);
86
- try {
87
- fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
88
- }
89
- finally {
90
- fs.closeSync(fd);
91
- }
92
- acquired = true;
93
- heldLocks.add(lockPath);
94
- return fn();
95
- }
96
- catch (error) {
97
- const errno = error;
98
- if (acquired || errno.code !== 'EEXIST')
99
- throw error;
100
- try {
101
- const stat = fs.statSync(lockPath);
102
- if (lockOwnerState(lockPath) === 'dead' || Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
103
- fs.unlinkSync(lockPath);
104
- continue;
105
- }
106
- }
107
- catch { }
108
- Atomics.wait(lockWait, 0, 0, LOCK_RETRY_MS);
109
- }
110
- finally {
111
- if (acquired) {
112
- heldLocks.delete(lockPath);
113
- try {
114
- fs.unlinkSync(lockPath);
115
- }
116
- catch { }
117
- }
118
- }
119
- }
120
- }
121
59
  function membershipKey(membership) {
122
60
  return `${canonicalSquarePath(membership.squarePath)}\u0000${membership.name.toLocaleLowerCase()}`;
123
61
  }
@@ -130,7 +68,7 @@ function withAttentionLocks(filePath, inbox, fn) {
130
68
  function acquire(index) {
131
69
  if (index >= lockPaths.length)
132
70
  return fn();
133
- return withFileLock(lockPaths[index], () => acquire(index + 1));
71
+ return withFileLockSync(lockPaths[index], { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, () => acquire(index + 1));
134
72
  }
135
73
  return acquire(0);
136
74
  }
@@ -154,24 +92,24 @@ function selectUnpresented(sessionId, inbox, rows) {
154
92
  : [{ membership: { ...membership, notifications }, ownerId }];
155
93
  });
156
94
  }
157
- export function hasPresentedForOwner(ownerId, squarePath, name, actIndex, env = process.env) {
95
+ export function hasPresentedForOwner(ownerId, squarePath, name, actIndex, env = process.env, now = Date.now()) {
158
96
  const resolved = canonicalSquarePath(squarePath);
159
- return readRows(presentedPath(env)).some((row) => row.owner_id === ownerId &&
97
+ return readRows(presentedPath(env), now).some((row) => row.owner_id === ownerId &&
160
98
  canonicalSquarePath(row.square_path) === resolved &&
161
99
  sameName(row.name, name) &&
162
100
  row.act_index === actIndex);
163
101
  }
164
102
  /** True when any current participant owner has already received this attention. */
165
- export function hasPresentedAttention(squarePath, name, actIndex, env = process.env) {
166
- const ownerIds = new Set(lookupParticipant(squarePath, name).map((binding) => binding.ownerId));
103
+ export function hasPresentedAttention(squarePath, name, actIndex, env = process.env, now = Date.now()) {
104
+ const ownerIds = new Set(lookupParticipant(squarePath, name, now).map((binding) => binding.ownerId));
167
105
  if (ownerIds.size === 0)
168
106
  return false;
169
- return [...ownerIds].some((ownerId) => hasPresentedForOwner(ownerId, squarePath, name, actIndex, env));
107
+ return [...ownerIds].some((ownerId) => hasPresentedForOwner(ownerId, squarePath, name, actIndex, env, now));
170
108
  }
171
109
  /**
172
110
  * Serialize presentation only for the affected participants. Delivery runs
173
111
  * outside the short ledger-write lock, so unrelated owners never wait on an
174
- * adapter. A throwing callback leaves no row and remains retryable.
112
+ * adapter. A throwing callback leaves no row and remains unpresented.
175
113
  */
176
114
  export function presentOnce(sessionId, lookup, deliver, env = process.env, at = Date.now()) {
177
115
  const filePath = presentedPath(env);
@@ -185,7 +123,7 @@ export function presentOnce(sessionId, lookup, deliver, env = process.env, at =
185
123
  if (selected.length === 0)
186
124
  return undefined;
187
125
  const result = deliver(selected.map(({ membership }) => membership));
188
- withFileLock(`${filePath}.lock`, () => {
126
+ withFileLockSync(`${filePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, () => {
189
127
  const rows = readRows(filePath, at);
190
128
  const known = new Set(rows.map(rowKey));
191
129
  for (const { membership, ownerId } of selected) {
package/dist/registry.js CHANGED
@@ -9,9 +9,9 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { homedir } from 'node:os';
11
11
  import { randomUUID } from 'node:crypto';
12
- import { loadSquare } from './artifact.js';
13
12
  import { nameKey, sameName } from './model.js';
14
13
  import { isCurrentlyJoined } from './runtime.js';
14
+ import { publishWakeRoutes, retireOwnerWakeRoutes } from './routes.js';
15
15
  const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
16
16
  const COMPACT_BYTES = 64 * 1024;
17
17
  const COMPACT_LINES = 1000;
@@ -231,19 +231,11 @@ export function localParticipantOwner(squarePath, name, env = process.env, now =
231
231
  return undefined;
232
232
  return lookupParticipant(squarePath, name, now).find((binding) => sessionIds.has(binding.sessionId))?.ownerId;
233
233
  }
234
- function bindingIsProvablyObsolete(binding) {
235
- if (!fs.existsSync(binding.squarePath))
236
- return true;
237
- try {
238
- return !isCurrentlyJoined(loadSquare(binding.squarePath).acts, binding.name);
239
- }
240
- catch {
241
- // A temporarily unreadable artifact is uncertain, so preserve its binding.
242
- return false;
243
- }
234
+ function bindingIsProvablyObsolete(binding, acts) {
235
+ return acts !== undefined && !isCurrentlyJoined(acts, binding.name);
244
236
  }
245
237
  /** Compact the registry and remove only bindings disproved by their authoritative artifact. */
246
- export function pruneRegistry(now = Date.now()) {
238
+ export function pruneRegistry(readActs, now = Date.now()) {
247
239
  const filePath = registryPath();
248
240
  let raw;
249
241
  try {
@@ -255,7 +247,7 @@ export function pruneRegistry(now = Date.now()) {
255
247
  throw error;
256
248
  }
257
249
  const active = foldRegistry(raw, now);
258
- const kept = active.filter((binding) => !bindingIsProvablyObsolete(binding));
250
+ const kept = active.filter((binding) => !bindingIsProvablyObsolete(binding, readActs(binding.squarePath)));
259
251
  writeRegistryBindings(filePath, kept);
260
252
  return { removed: active.length - kept.length, kept: kept.length };
261
253
  }
@@ -280,25 +272,29 @@ export function hasAutomaticDeliveryIdentity(env = process.env) {
280
272
  export function recordLocalJoin(name, squarePath, env = process.env) {
281
273
  const at = Date.now();
282
274
  const identities = localSessionIdentities(env);
283
- const identityIds = new Set(identities.map((identity) => identity.sessionId));
284
275
  const current = lookupParticipant(squarePath, name, at);
285
- const ownerId = current.find((binding) => identityIds.has(binding.sessionId))?.ownerId ?? nextOwnerId();
276
+ const ownerId = nextOwnerId();
277
+ for (const binding of current) {
278
+ recordDone(binding.sessionId, binding.name, binding.squarePath, {
279
+ channel: binding.channel,
280
+ child: binding.child,
281
+ ...(binding.paseoAgentId ? { paseoAgentId: binding.paseoAgentId } : {}),
282
+ at,
283
+ });
284
+ }
286
285
  for (const identity of identities) {
287
286
  recordJoin(identity.sessionId, name, squarePath, { ...identity, at, ownerId });
288
287
  }
288
+ publishWakeRoutes(ownerId, { at, env });
289
+ for (const previousOwnerId of new Set(current.map((binding) => binding.ownerId))) {
290
+ if (previousOwnerId !== ownerId)
291
+ retireOwnerWakeRoutes(previousOwnerId, { at, env });
292
+ }
289
293
  }
290
294
  export function recordLocalDone(name, squarePath, env = process.env) {
291
295
  const at = Date.now();
292
- const identities = localSessionIdentities(env);
293
- const identityIds = new Set(identities.map((identity) => identity.sessionId));
294
296
  const current = lookupParticipant(squarePath, name, at);
295
- const ownerId = current.find((binding) => identityIds.has(binding.sessionId))?.ownerId;
296
- if (ownerId === undefined) {
297
- for (const identity of identities)
298
- recordDone(identity.sessionId, name, squarePath, { ...identity, at });
299
- return;
300
- }
301
- for (const binding of current.filter((candidate) => candidate.ownerId === ownerId)) {
297
+ for (const binding of current) {
302
298
  recordDone(binding.sessionId, binding.name, binding.squarePath, {
303
299
  channel: binding.channel,
304
300
  child: binding.child,
@@ -306,4 +302,7 @@ export function recordLocalDone(name, squarePath, env = process.env) {
306
302
  at,
307
303
  });
308
304
  }
305
+ for (const ownerId of new Set(current.map((binding) => binding.ownerId))) {
306
+ retireOwnerWakeRoutes(ownerId, { at, env });
307
+ }
309
308
  }
package/dist/routes.js ADDED
@@ -0,0 +1,153 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { WAKE_ROUTE_KINDS, isWakeRouteKind } from './model.js';
5
+ export { isWakeRouteKind, WAKE_ROUTE_KINDS } from './model.js';
6
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
7
+ export const ROUTE_FRESH_MS = 24 * 60 * 60 * 1000;
8
+ /**
9
+ * Dispatch priority doubles as the kind declaration order: native kinds
10
+ * precede paseo. Within one kind, the freshest route wins.
11
+ */
12
+ const ROUTE_KIND_PRIORITY = new Map(WAKE_ROUTE_KINDS.map((kind, index) => [kind, index]));
13
+ export function routesPath(env = process.env) {
14
+ return env.SQUARE_ROUTES || path.join(os.homedir(), '.square', 'routes.ndjsonl');
15
+ }
16
+ function routeKey(ownerId, kind) {
17
+ return `${ownerId}\0${kind}`;
18
+ }
19
+ function stringRecord(value) {
20
+ return value !== null && typeof value === 'object' && !Array.isArray(value) &&
21
+ Object.values(value).every((item) => typeof item === 'string');
22
+ }
23
+ function parseRow(raw, now) {
24
+ let value;
25
+ try {
26
+ value = JSON.parse(raw);
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ if (value === null || typeof value !== 'object')
32
+ return undefined;
33
+ const row = value;
34
+ if (row.v !== 1 ||
35
+ (row.op !== 'upsert' && row.op !== 'retire') ||
36
+ typeof row.ts !== 'number' || !Number.isFinite(row.ts) || row.ts > now || now - row.ts > RETENTION_MS ||
37
+ typeof row.owner_id !== 'string' || row.owner_id === '' ||
38
+ typeof row.session_id !== 'string' || row.session_id === '' ||
39
+ !isWakeRouteKind(row.kind))
40
+ return undefined;
41
+ if (row.op === 'upsert' && !stringRecord(row.address))
42
+ return undefined;
43
+ return row;
44
+ }
45
+ function readRows(env, now) {
46
+ let raw;
47
+ try {
48
+ raw = fs.readFileSync(routesPath(env), 'utf8');
49
+ }
50
+ catch (error) {
51
+ if (error.code === 'ENOENT')
52
+ return [];
53
+ throw error;
54
+ }
55
+ return raw.split('\n').filter(Boolean).map((line) => parseRow(line, now)).filter((row) => row !== undefined);
56
+ }
57
+ export function readWakeRoutes(opts = {}) {
58
+ const now = opts.now ?? Date.now();
59
+ const state = new Map();
60
+ for (const row of readRows(opts.env ?? process.env, now)) {
61
+ const key = routeKey(row.owner_id, row.kind);
62
+ const current = state.get(key);
63
+ if (current === undefined || row.ts >= current.ts)
64
+ state.set(key, row);
65
+ }
66
+ return [...state.values()]
67
+ .filter((row) => row.op === 'upsert')
68
+ .map((row) => ({
69
+ ownerId: row.owner_id,
70
+ sessionId: row.session_id,
71
+ kind: row.kind,
72
+ address: row.address,
73
+ updatedAt: row.ts,
74
+ }))
75
+ .filter((route) => opts.ownerId === undefined || route.ownerId === opts.ownerId)
76
+ .filter((route) => opts.freshOnly !== true || now - route.updatedAt < ROUTE_FRESH_MS)
77
+ .sort((a, b) => (ROUTE_KIND_PRIORITY.get(a.kind) ?? 0) - (ROUTE_KIND_PRIORITY.get(b.kind) ?? 0) ||
78
+ b.updatedAt - a.updatedAt);
79
+ }
80
+ function appendRouteRow(row, env) {
81
+ const file = routesPath(env);
82
+ fs.mkdirSync(path.dirname(file), { recursive: true });
83
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
84
+ }
85
+ export function upsertWakeRoute(route, opts = {}) {
86
+ const at = opts.at ?? Date.now();
87
+ appendRouteRow({
88
+ v: 1,
89
+ ts: at,
90
+ op: 'upsert',
91
+ owner_id: route.ownerId,
92
+ session_id: route.sessionId,
93
+ kind: route.kind,
94
+ address: route.address,
95
+ }, opts.env ?? process.env);
96
+ }
97
+ export function retireOwnerWakeRoutes(ownerId, opts = {}) {
98
+ const at = opts.at ?? Date.now();
99
+ const env = opts.env ?? process.env;
100
+ for (const route of readWakeRoutes({ ownerId, now: at, env })) {
101
+ appendRouteRow({
102
+ v: 1,
103
+ ts: at,
104
+ op: 'retire',
105
+ owner_id: ownerId,
106
+ session_id: route.sessionId,
107
+ kind: route.kind,
108
+ }, env);
109
+ }
110
+ }
111
+ /** Publication requires a non-blank session and a non-empty address of non-blank values. */
112
+ function completeRouteEvidence(value) {
113
+ if (value === undefined || value === null)
114
+ return false;
115
+ if (typeof value.sessionId !== 'string' || value.sessionId.trim() === '')
116
+ return false;
117
+ const address = value.address;
118
+ if (address === null || typeof address !== 'object' || Array.isArray(address))
119
+ return false;
120
+ const entries = Object.entries(address);
121
+ return entries.length > 0 && entries.every(([key, item]) => key.trim() !== '' && typeof item === 'string' && item.trim() !== '');
122
+ }
123
+ /**
124
+ * One probe per kind. A probe publishes only when its provider's complete
125
+ * endpoint evidence is present; session identity alone never publishes. The
126
+ * four native transports have no endpoint lifecycle in this delivery, so
127
+ * their probes publish nothing until those transports land.
128
+ */
129
+ export const WAKE_ROUTE_PROBES = {
130
+ 'opencode-server': () => undefined,
131
+ 'codex-app-server': () => undefined,
132
+ 'claude-native': () => undefined,
133
+ 'pi-extension': () => undefined,
134
+ paseo: (env) => {
135
+ const agentId = env.PASEO_AGENT_ID?.trim();
136
+ return agentId ? { sessionId: agentId, address: { agentId } } : undefined;
137
+ },
138
+ };
139
+ /** The kind-neutral publication loop; probes supply complete evidence per kind. */
140
+ export function publishWakeRoutesFrom(ownerId, probes, opts = {}) {
141
+ const at = opts.at ?? Date.now();
142
+ const env = opts.env ?? process.env;
143
+ for (const kind of WAKE_ROUTE_KINDS) {
144
+ const evidence = probes[kind](env);
145
+ if (!completeRouteEvidence(evidence))
146
+ continue;
147
+ upsertWakeRoute({ ownerId, sessionId: evidence.sessionId, kind, address: evidence.address }, { at, env });
148
+ }
149
+ }
150
+ /** Publication boundary: every route written is complete provider evidence. */
151
+ export function publishWakeRoutes(ownerId, opts = {}) {
152
+ publishWakeRoutesFrom(ownerId, WAKE_ROUTE_PROBES, opts);
153
+ }
@@ -1,56 +1,15 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { setTimeout as sleep } from 'node:timers/promises';
4
3
  import { emptyRuntimeState, loadRuntimeSidecar, loadSquare, mergeRuntimeState, renderArtifactAct, renderSquare, renderSquareDoc, saveRuntimeSidecar } from './artifact.js';
5
4
  import { coreCompact, coreDone, coreHold, coreResume, decideAct, decideJoin, resolveKnownName } from './decisions.js';
6
- import { dispatchActNotifications } from './notifications.js';
7
5
  import { planRepair } from './doctor.js';
6
+ import { withFileLock } from './file-lock.js';
8
7
  import { stageReplacement } from './harness-stage.js';
9
8
  import { SquareError } from './model.js';
10
9
  import { advanceCursor, freshWatchLease, LOCK_RETRY_MS, LOCK_STALE_MS, removeWatchLease, touchPresenceCursor, watchLease, writeWatchLease } from './runtime.js';
11
10
  /** The only persistence primitive: one per-square lock, one Markdown write, one sidecar write. */
12
11
  export async function withSquareLock(squarePath, fn) {
13
- const lockPath = `${squarePath}.lock`;
14
- fs.mkdirSync(path.dirname(lockPath), { recursive: true });
15
- while (true) {
16
- let fd;
17
- try {
18
- fd = fs.openSync(lockPath, 'wx');
19
- fs.writeFileSync(fd, `${process.pid}\n${Date.now()}\n`, 'utf8');
20
- }
21
- catch (error) {
22
- if (fd !== undefined) {
23
- try {
24
- fs.closeSync(fd);
25
- }
26
- catch { }
27
- try {
28
- fs.unlinkSync(lockPath);
29
- }
30
- catch { }
31
- }
32
- const errno = error;
33
- if (errno.code !== 'EEXIST')
34
- throw error;
35
- try {
36
- if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS)
37
- fs.unlinkSync(lockPath);
38
- }
39
- catch { }
40
- await sleep(LOCK_RETRY_MS);
41
- continue;
42
- }
43
- fs.closeSync(fd);
44
- try {
45
- return await fn();
46
- }
47
- finally {
48
- try {
49
- fs.unlinkSync(lockPath);
50
- }
51
- catch { }
52
- }
53
- }
12
+ return withFileLock(`${squarePath}.lock`, { retryMs: LOCK_RETRY_MS, staleMs: LOCK_STALE_MS }, fn);
54
13
  }
55
14
  export function writeSquareDoc(squarePath, doc) {
56
15
  const temporary = path.join(path.dirname(squarePath), `.${path.basename(squarePath)}.${process.pid}.${Date.now()}.tmp`);
@@ -136,6 +95,50 @@ function plan(doc, intent) {
136
95
  mutateRuntime: (nextDoc) => { removeWatchLease(nextDoc, name, intent.leaseId); },
137
96
  };
138
97
  }
98
+ case 'claim-notify': {
99
+ const current = doc.runtime.notifyLeases[intent.key];
100
+ if (current !== undefined && current.expiresAt > intent.at)
101
+ return { result: { type: 'busy' }, acts: [] };
102
+ if (current?.phase === 'dispatching')
103
+ return { result: { type: 'ambiguous', lease: current }, acts: [] };
104
+ return {
105
+ result: { type: 'acquired', leaseId: intent.leaseId },
106
+ acts: [],
107
+ mutateRuntime: (nextDoc) => {
108
+ nextDoc.runtime.notifyLeases[intent.key] = {
109
+ leaseId: intent.leaseId,
110
+ expiresAt: intent.expiresAt,
111
+ phase: 'claimed',
112
+ };
113
+ },
114
+ };
115
+ }
116
+ case 'transition-notify': {
117
+ if (doc.runtime.notifyLeases[intent.key]?.leaseId !== intent.leaseId)
118
+ return { result: { updated: false }, acts: [] };
119
+ return {
120
+ result: { updated: true },
121
+ acts: [],
122
+ mutateRuntime: (nextDoc) => {
123
+ nextDoc.runtime.notifyLeases[intent.key] = {
124
+ leaseId: intent.leaseId,
125
+ expiresAt: intent.expiresAt,
126
+ phase: intent.phase,
127
+ ...(intent.attemptN === undefined ? {} : { attemptN: intent.attemptN }),
128
+ ...(intent.routeKind === undefined ? {} : { routeKind: intent.routeKind }),
129
+ };
130
+ },
131
+ };
132
+ }
133
+ case 'release-notify': {
134
+ if (doc.runtime.notifyLeases[intent.key]?.leaseId !== intent.leaseId)
135
+ return { result: { released: false }, acts: [] };
136
+ return {
137
+ result: { released: true },
138
+ acts: [],
139
+ mutateRuntime: (nextDoc) => { delete nextDoc.runtime.notifyLeases[intent.key]; },
140
+ };
141
+ }
139
142
  case 'consume': {
140
143
  const name = resolveKnownName(doc, intent.name);
141
144
  return {
@@ -199,15 +202,10 @@ function commitPlan(squarePath, doc, planned) {
199
202
  }
200
203
  /** The one mutation pipeline shared by package and CLI adapters. */
201
204
  export async function execute(squarePath, intent) {
202
- const committed = await withSquareLock(squarePath, () => {
205
+ return withSquareLock(squarePath, () => {
203
206
  const doc = loadSquare(squarePath);
204
207
  return commitPlan(squarePath, doc, plan(doc, intent));
205
208
  });
206
- for (const act of committed.acts) {
207
- if (act.kind === 'say')
208
- await dispatchActNotifications(squarePath, act);
209
- }
210
- return committed;
211
209
  }
212
210
  /** Application-owned artifact creation; adapters provide validated options and stdin text only. */
213
211
  export async function createSquare(squarePath, options, snippet) {
package/dist/stream.js CHANGED
@@ -45,6 +45,6 @@ export async function cmdStreamNdjson(squarePath, recipient) {
45
45
  }
46
46
  export async function cmdStream(squarePath) {
47
47
  process.stderr.write('✕ interactive stream was removed\n');
48
- process.stderr.write(`» square --square-path ${quoteShell(path.resolve(squarePath))} stream --ndjson\n`);
48
+ process.stderr.write(`» square --location ${quoteShell(path.resolve(squarePath))} stream --ndjson\n`);
49
49
  process.exitCode = 2;
50
50
  }