@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.
- package/codex-plugin/.codex-plugin/plugin.json +1 -1
- package/dist/activity.js +4 -0
- package/dist/artifact.js +33 -2
- package/dist/cli/context.js +1 -1
- package/dist/cli/maintenance-commands.js +12 -1
- package/dist/cli/observation-commands.js +3 -16
- package/dist/cli/program.js +0 -4
- package/dist/cli/square-commands.js +23 -3
- package/dist/cmd/notify-once.js +5 -15
- package/dist/decisions.js +10 -2
- package/dist/delivery-health.js +55 -136
- package/dist/doctor.js +1 -0
- package/dist/file-lock.js +112 -0
- package/dist/harness-codex.js +35 -29
- package/dist/harness-links.js +0 -3
- package/dist/harness-pi.js +57 -0
- package/dist/harness.js +10 -15
- package/dist/help.js +8 -8
- package/dist/index.js +5 -1
- package/dist/model.js +4 -0
- package/dist/notifications.js +205 -28
- package/dist/paseo-connection.js +135 -0
- package/dist/paseo-delivery.js +73 -144
- package/dist/paseo-state.js +1 -1
- package/dist/paseo-timeline.js +32 -42
- package/dist/presentation.js +2 -2
- package/dist/presented.js +10 -72
- package/dist/registry.js +23 -24
- package/dist/routes.js +153 -0
- package/dist/square-application.js +47 -49
- package/dist/stream.js +1 -1
- package/dist/wake-attempts.js +171 -0
- package/dist/wake-evidence.js +35 -0
- package/dist/wake-port.js +22 -0
- package/dist/wake-sink.js +45 -6
- package/dist/watch.js +1 -2
- package/guides/participant.md +1 -1
- package/package.json +6 -1
- package/skills/brainstorm/SKILL.md +24 -24
- package/skills/square/.claude-plugin/plugin.json +1 -1
- package/skills/square/SKILL.md +4 -3
- package/skills/square-feedback/SKILL.md +2 -2
- package/dist/notification-failures.js +0 -54
package/dist/paseo-timeline.js
CHANGED
|
@@ -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
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/presentation.js
CHANGED
|
@@ -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 --
|
|
34
|
+
return `square --location ${quoteShell(squarePath)}`;
|
|
35
35
|
}
|
|
36
36
|
export function participantCommandPrefix(squarePath, name) {
|
|
37
|
-
return `square --
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 --
|
|
48
|
+
process.stderr.write(`» square --location ${quoteShell(path.resolve(squarePath))} stream --ndjson\n`);
|
|
49
49
|
process.exitCode = 2;
|
|
50
50
|
}
|