@astrosheep/square 0.3.31 → 0.3.32

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.31",
3
+ "version": "0.3.32",
4
4
  "description": "Native Claude Code turn-boundary delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.31",
3
+ "version": "0.3.32",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
package/dist/activity.js CHANGED
@@ -11,6 +11,8 @@ import { Square } from './square-wiring.js';
11
11
  import { activityPresentation, resolveParticipant } from './views.js';
12
12
  import { formatActivityId } from './square-core.js';
13
13
  import { formatTimestamp } from './time.js';
14
+ import { createHostLedgerPort } from './host-ledger-file-adapter.js';
15
+ import { createDefaultWakeTransport } from './notifications.js';
14
16
  function draftDirFor(squarePath) {
15
17
  return path.join(path.dirname(squarePath), 'drafts');
16
18
  }
@@ -66,7 +68,9 @@ export async function cmdActivity(squarePath, name, activity, resolveBody, opts)
66
68
  const noWait = opts.noWait ?? false;
67
69
  const reach = opts.reach === 'bell' ? 'bell' : undefined;
68
70
  let announcedWait;
69
- const square = await Square.at({ path: squarePath, clock: nowMs });
71
+ const ledgerRoot = process.env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(process.env.SQUARE_REGISTRY);
72
+ const hostLedger = createHostLedgerPort({ userPath: process.env.SQUARE_HOST_LEDGER_USER ?? ledgerRoot, localPath: process.env.SQUARE_HOST_LEDGER_LOCAL ?? ledgerRoot });
73
+ const square = await Square.at({ path: squarePath, clock: nowMs, hostLedger, wakeTransport: await createDefaultWakeTransport(hostLedger, nowMs) });
70
74
  try {
71
75
  const participant = await square.join(name);
72
76
  while (true) {
@@ -80,11 +84,8 @@ export async function cmdActivity(squarePath, name, activity, resolveBody, opts)
80
84
  ...(reach === undefined ? {} : { reach }),
81
85
  ...(opts.reply === undefined ? {} : { reply: formatActivityId(opts.reply) }),
82
86
  });
83
- try {
84
- }
85
- catch {
86
- // Compatibility wake is post-commit and cannot undo the activity.
87
- }
87
+ if (result.delivery?.notCapable)
88
+ process.stderr.write(`! wake not-capable: ${result.delivery.notCapable}\n`);
88
89
  const freshSquare = await openSquare(squarePath, { clock: nowMs });
89
90
  const fresh = await activityPresentation(freshSquare, knownName).finally(() => closeOpenSquare(freshSquare));
90
91
  const headerCount = fresh.participantCount;
package/dist/artifact.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { TextDecoder } from 'node:util';
5
5
  import zlib from 'node:zlib';
6
- import { InternalSquareError, SquareError, } from './model.js';
6
+ import { isWakeRouteKind, InternalSquareError, SquareError, } from './model.js';
7
7
  import { parseActivityId } from './square-core.js';
8
8
  const SQUARE_MAGIC = Buffer.from('SQUARE01', 'ascii');
9
9
  const LENGTH_BYTES = 4;
@@ -138,13 +138,14 @@ function validateActs(value) {
138
138
  }
139
139
  function validateSquareState(value) {
140
140
  if (!isObject(value)
141
- || !hasExactKeys(value, ['hardCap', 'preamble', 'warmup', 'acts', 'runtime'], ['throttlePerMinute'])
141
+ || !hasExactKeys(value, ['hardCap', 'preamble', 'warmup', 'acts', 'runtime'], ['throttlePerMinute', 'routes'])
142
142
  || !(value.hardCap === null || (Number.isSafeInteger(value.hardCap) && value.hardCap > 0))
143
143
  || (value.throttlePerMinute !== undefined
144
144
  && (!Number.isSafeInteger(value.throttlePerMinute) || value.throttlePerMinute <= 0))
145
145
  || !isStringArray(value.preamble)
146
146
  || !isStringArray(value.warmup)
147
147
  || !validateActs(value.acts)
148
+ || (value.routes !== undefined && (!Array.isArray(value.routes) || !value.routes.every((route) => isObject(route) && isWakeRouteKind(route.kind) && typeof route.location === 'string' && typeof route.participant === 'string' && typeof route.sessionId === 'string' && typeof route.channel === 'string' && isObject(route.address) && Object.values(route.address).every((item) => typeof item === 'string') && typeof route.updatedAt === 'number')))
148
149
  || !validateRuntime(value.runtime)) {
149
150
  throw invalidArtifact('snapshot schema is malformed.');
150
151
  }
@@ -241,6 +242,7 @@ export function createSquareState(options, snippet) {
241
242
  preamble: normalizedLines(snippet),
242
243
  warmup: normalizedLines(guides.join('\n\n')),
243
244
  acts: [],
245
+ routes: [],
244
246
  runtime: emptyRuntimeState(),
245
247
  };
246
248
  }
@@ -7,6 +7,7 @@ import { entryPresentation } from './views.js';
7
7
  import { automaticParticipant } from './participant-identity.js';
8
8
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
9
9
  import { projectSessionBindings } from './square-projections.js';
10
+ import { publishWakeRoute, retireWakeRouteFromArtifact, resolvePrimaryWakeRoute, defaultWakeRouteCapabilities } from './routes.js';
10
11
  export { automaticParticipant } from './participant-identity.js';
11
12
  const providerEnv = {
12
13
  codex: 'CODEX_THREAD_ID',
@@ -56,7 +57,17 @@ export async function automaticSessionStart(provider, sessionId, cwd, env = proc
56
57
  const implicit = await square.implicitJoin(name);
57
58
  if (implicit.state === 'done')
58
59
  return undefined;
59
- await hostLedgerForEnv(env).ensurePresence({ location: squarePath, participant: name, session: sessionId, channel: provider === 'claude' ? 'claude-code' : provider, updatedAt: Date.now() });
60
+ const route = resolvePrimaryWakeRoute({ location: squarePath, participant: name, sessionId, provider }, env, await defaultWakeRouteCapabilities(hostLedgerForEnv(env)));
61
+ if (route !== undefined) {
62
+ const publisher = await openSquare(squarePath, { hostLedger: hostLedgerForEnv(scopedEnv), env: scopedEnv });
63
+ try {
64
+ await publishWakeRoute(publisher.artifact, route, { at: Date.now() });
65
+ }
66
+ finally {
67
+ await closeOpenSquare(publisher);
68
+ }
69
+ }
70
+ await hostLedgerForEnv(env).ensurePresence({ location: squarePath, participant: name, session: sessionId, channel: provider === 'claude' ? 'claude-code' : provider, updatedAt: Date.now() }, 'user');
60
71
  await square.reconcileBinding();
61
72
  return undefined;
62
73
  }
@@ -87,6 +98,13 @@ export async function automaticSessionEnd(provider, sessionId, cwd, env = proces
87
98
  const participant = await square.join(binding.participant);
88
99
  await participant.done();
89
100
  await square.reconcileBinding();
101
+ const cleanup = await openSquare(squarePath);
102
+ try {
103
+ await retireWakeRouteFromArtifact(cleanup.artifact, { location: squarePath, participant: binding.participant, sessionId });
104
+ }
105
+ finally {
106
+ await closeOpenSquare(cleanup);
107
+ }
90
108
  }
91
109
  finally {
92
110
  await square.close();
@@ -1,9 +1,10 @@
1
1
  import type { InboxMembership } from './model.js';
2
+ import type { WakeAdapter } from './delivery.js';
2
3
  export interface NativeHookInput {
3
4
  session_id?: unknown;
4
5
  hook_event_name?: unknown;
5
6
  cwd?: unknown;
6
7
  }
7
8
  export declare function runClaudeHookAsync(inputText: string, env?: NodeJS.ProcessEnv): Promise<string>;
8
- export declare function claudeHookResponse(input: NativeHookInput, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv): Promise<object | undefined>;
9
+ export declare function claudeHookResponse(input: NativeHookInput, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv, deliveryAdapters?: WakeAdapter[]): Promise<object | undefined>;
9
10
  export declare function runClaudeHook(inputText: string, env?: NodeJS.ProcessEnv): Promise<string>;
@@ -1,6 +1,7 @@
1
1
  import { presentPendingAtBoundary } from './boundary-presentation.js';
2
2
  import { sessionInbox } from './inbox.js';
3
3
  import { automaticSessionEnd, automaticSessionStart } from './automatic-session.js';
4
+ import { sweepPrivilegedPending } from './notifications.js';
4
5
  export async function runClaudeHookAsync(inputText, env = process.env) {
5
6
  let input;
6
7
  try {
@@ -34,12 +35,14 @@ export async function runClaudeHookAsync(inputText, env = process.env) {
34
35
  }
35
36
  return runClaudeHook(inputText, env);
36
37
  }
37
- export async function claudeHookResponse(input, lookup = sessionInbox, env = process.env) {
38
+ export async function claudeHookResponse(input, lookup = sessionInbox, env = process.env, deliveryAdapters) {
38
39
  if (typeof input.session_id !== 'string' || input.session_id === '')
39
40
  return undefined;
40
41
  if (input.hook_event_name !== 'PostToolBatch')
41
42
  return undefined;
42
- return presentPendingAtBoundary(input.session_id, (context) => ({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: context } }), lookup, env);
43
+ const response = await presentPendingAtBoundary(input.session_id, (context) => ({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: context } }), lookup, env);
44
+ await sweepPrivilegedPending(typeof input.cwd === 'string' ? input.cwd : process.cwd(), env, deliveryAdapters).catch(() => undefined);
45
+ return response;
43
46
  }
44
47
  export async function runClaudeHook(inputText, env = process.env) {
45
48
  let input;
@@ -4,12 +4,12 @@ import { participantCommandPrefix, participantIdentity, renderEventCli, renderAm
4
4
  import { hasAutomaticDeliveryIdentity, } from '../registry.js';
5
5
  import { createHostLedgerPort } from '../host-ledger-file-adapter.js';
6
6
  import { projectLocalParticipantBinding, sessionIdsFromEnvironment } from '../square-projections.js';
7
- import { sweepPendingNotifications } from '../notifications.js';
8
7
  import { nowMs } from '../runtime.js';
9
8
  import { createSquare, openSquare } from '../square-file-adapter.js';
10
9
  import { closeOpenSquare } from '../open-square.js';
11
10
  import { openParticipant, Square } from '../square-wiring.js';
12
11
  import { entryPresentation, eventPresentation } from '../views.js';
12
+ import { createDefaultWakeTransport } from '../notifications.js';
13
13
  import { fail, parseHardCap, parsePositiveInteger, readStdin, requireParticipant, requireSquarePath, requireValue, resolveBody, usage, } from './context.js';
14
14
  function parseBuild(argv) {
15
15
  const options = { force: false, hardCap: null };
@@ -86,7 +86,8 @@ export const joinCommand = {
86
86
  const beforeSquare = await openSquare(squarePath, { clock: nowMs });
87
87
  const before = await entryPresentation(beforeSquare, intent.name, intent.lastN);
88
88
  await closeOpenSquare(beforeSquare);
89
- const square = await Square.at({ path: squarePath, clock: nowMs });
89
+ const hostLedger = createHostLedgerPort();
90
+ const square = await Square.at({ path: squarePath, clock: nowMs, hostLedger, wakeTransport: await createDefaultWakeTransport(hostLedger, nowMs) });
90
91
  try {
91
92
  const reconnect = before.joined
92
93
  && await projectLocalParticipantBinding({
@@ -109,8 +110,7 @@ export const joinCommand = {
109
110
  const afterSquare = await openSquare(squarePath, { clock: nowMs });
110
111
  const after = await entryPresentation(afterSquare, joinedName, intent.lastN);
111
112
  await closeOpenSquare(afterSquare);
112
- await square.reconcileBinding();
113
- await sweepPendingNotifications(squarePath);
113
+ await square.reconcileBinding().catch(() => undefined);
114
114
  const activities = after.recentActivities.map((event) => renderAmbientEvent(event, joinedName, {
115
115
  now: nowMs(),
116
116
  preview: intent.lastN === null ? undefined : 200,
@@ -182,7 +182,6 @@ export const expressCommand = {
182
182
  parse: parseActivity,
183
183
  async execute(intent, context) {
184
184
  const squarePath = requireSquarePath(context);
185
- await sweepPendingNotifications(squarePath);
186
185
  const body = await resolveBody(intent.activity);
187
186
  const reachArg = intent.reach === 'bell' ? ' --bell' : '';
188
187
  await cmdActivity(squarePath, intent.name, body, (value) => value, {
@@ -1,10 +1,11 @@
1
1
  import type { InboxMembership } from './model.js';
2
+ import type { WakeAdapter } from './delivery.js';
2
3
  export interface CodexHookInput {
3
4
  session_id?: unknown;
4
5
  hook_event_name?: unknown;
5
6
  cwd?: unknown;
6
7
  source?: unknown;
7
8
  }
8
- export declare function codexHookResponse(input: CodexHookInput, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv): Promise<object | undefined>;
9
+ export declare function codexHookResponse(input: CodexHookInput, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv, deliveryAdapters?: WakeAdapter[]): Promise<object | undefined>;
9
10
  export declare function runCodexHook(inputText: string, env?: NodeJS.ProcessEnv): Promise<string>;
10
11
  export declare function runCodexHookAsync(inputText: string, env?: NodeJS.ProcessEnv): Promise<string>;
@@ -2,11 +2,12 @@ import { presentPendingAtBoundary } from './boundary-presentation.js';
2
2
  import { sessionInbox } from './inbox.js';
3
3
  import { automaticSessionEnd, automaticSessionStart } from './automatic-session.js';
4
4
  import { clearCodexBoundary, recordCodexBoundary } from './codex-boundary-state.js';
5
+ import { sweepPrivilegedPending } from './notifications.js';
5
6
  const CODEX_HOOK_EVENTS = {
6
7
  PostToolUse: 'PostToolUse',
7
8
  Stop: 'Stop',
8
9
  };
9
- export async function codexHookResponse(input, lookup = sessionInbox, env = process.env) {
10
+ export async function codexHookResponse(input, lookup = sessionInbox, env = process.env, deliveryAdapters) {
10
11
  if (typeof input.session_id !== 'string' || input.session_id === '')
11
12
  return undefined;
12
13
  if (typeof input.hook_event_name !== 'string')
@@ -15,9 +16,11 @@ export async function codexHookResponse(input, lookup = sessionInbox, env = proc
15
16
  if (hookEventName === undefined)
16
17
  return undefined;
17
18
  await recordCodexBoundary(input.session_id, hookEventName === 'Stop' ? 'Stop' : 'non-stop', env);
18
- return presentPendingAtBoundary(input.session_id, (context) => hookEventName === 'Stop'
19
+ const response = await presentPendingAtBoundary(input.session_id, (context) => hookEventName === 'Stop'
19
20
  ? { systemMessage: context }
20
21
  : { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } }, lookup, env);
22
+ await sweepPrivilegedPending(typeof input.cwd === 'string' ? input.cwd : process.cwd(), env, deliveryAdapters).catch(() => undefined);
23
+ return response;
21
24
  }
22
25
  export async function runCodexHook(inputText, env = process.env) {
23
26
  let input;
@@ -36,7 +36,7 @@ export class CodexQueueAdapter {
36
36
  async dispatch(address, payload, beforeSend) {
37
37
  const threadId = address.threadId?.trim();
38
38
  if (!threadId) {
39
- return { outcome: 'unavailable', signature: 'invalid_address', message: 'Codex route has no thread id.' };
39
+ return { outcome: 'unavailable', signature: 'invalid_address', message: 'Codex route has no thread id.', routeStale: true };
40
40
  }
41
41
  const env = this.opts.env ?? process.env;
42
42
  if (!await codexQueueEligible(threadId, env)) {
@@ -1,11 +1,21 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { formatActivityId, parseActivityId } from './square-core.js';
3
+ import { nameKey } from './model.js';
3
4
  import { deriveDeliveryModel } from './delivery.js';
4
5
  import { isWakeRouteAttemptable } from './square-projections.js';
6
+ import { retireWakeRouteFromArtifact } from './routes.js';
5
7
  export async function observeSquare(input) {
6
8
  const snapshot = await input.artifact.read();
7
9
  const delivery = deriveDeliveryModel(snapshot.state);
8
- const rows = input.hostLedger === undefined ? [] : await input.hostLedger.listPresence({ location: input.location, scopes: ['user', 'local'], now: input.now });
10
+ let rows = [];
11
+ if (input.hostLedger !== undefined) {
12
+ try {
13
+ rows = await input.hostLedger.listPresence({ location: input.location, scopes: ['user', 'local'], now: input.now });
14
+ }
15
+ catch {
16
+ rows = [];
17
+ }
18
+ }
9
19
  const bindings = rows.map((record) => ({
10
20
  location: record.location,
11
21
  participant: record.participant,
@@ -21,40 +31,94 @@ export async function reconcileBinding(input) {
21
31
  }
22
32
  export async function deliverPending(input) {
23
33
  const observation = await observeSquare({ artifact: input.artifact, hostLedger: input.hostLedger, location: input.location, now: input.now });
24
- const routes = (await input.hostLedger.listPresence({ location: input.location, scopes: ['user'], now: input.now })).filter((binding) => binding.route !== undefined);
34
+ const routes = (observation.state.routes ?? []).map((route) => ({
35
+ location: route.location,
36
+ participant: route.participant,
37
+ session: route.sessionId,
38
+ channel: route.channel,
39
+ route: { kind: route.kind, address: route.address },
40
+ updatedAt: route.updatedAt,
41
+ }));
25
42
  let attempted = 0;
26
43
  let accepted = 0;
27
44
  let failed = 0;
28
45
  let unknown = 0;
46
+ let notCapable = 0;
29
47
  for (const membership of observation.pending) {
30
48
  for (const notification of membership.notifications) {
31
- const candidates = routes.filter((route) => route.participant.toLocaleLowerCase() === membership.recipient.toLocaleLowerCase());
49
+ const candidates = routes.filter((route) => nameKey(route.participant) === nameKey(membership.recipient));
50
+ if (candidates.length === 0) {
51
+ notCapable += 1;
52
+ continue;
53
+ }
32
54
  if (input.activity !== undefined) {
33
55
  const requested = typeof input.activity === 'number' ? input.activity : parseActivityId(input.activity);
34
56
  if (requested === undefined || requested !== notification.item.index)
35
57
  continue;
36
58
  }
59
+ let acceptedForAttention = false;
60
+ try {
61
+ const prior = await input.hostLedger.listWakeAttempts({ attention: { squarePath: input.location, actIndex: notification.item.index, recipient: membership.recipient }, now: input.now });
62
+ acceptedForAttention = prior.some((attempt) => attempt.outcome === 'accepted');
63
+ }
64
+ catch { /* capability is handled by the route-level probe */ }
65
+ if (acceptedForAttention)
66
+ continue;
37
67
  for (const route of candidates) {
38
68
  const activity = formatActivityId(notification.item.index);
39
69
  const attention = { squarePath: input.location, actIndex: notification.item.index, recipient: membership.recipient };
40
70
  const leaseMs = input.timeoutMs ?? 5000;
71
+ const requestRoute = { location: route.location, participant: route.participant, sessionId: route.session, channel: route.channel, kind: route.route.kind, address: { ...route.route.address }, updatedAt: route.updatedAt ?? 0 };
72
+ if (input.transport.probe !== undefined) {
73
+ try {
74
+ const probe = await input.transport.probe(requestRoute);
75
+ if (probe === false || (typeof probe === 'object' && probe.outcome === 'not-capable')) {
76
+ notCapable += 1;
77
+ continue;
78
+ }
79
+ }
80
+ catch {
81
+ notCapable += 1;
82
+ continue;
83
+ }
84
+ }
41
85
  let leaseId = randomUUID();
42
- let lease = await input.hostLedger.claimWakeDispatch({ attention, leaseId, leaseMs, session: route.session, at: input.now });
86
+ let lease;
87
+ try {
88
+ lease = await input.hostLedger.claimWakeDispatch({ attention, leaseId, leaseMs, session: route.session, at: input.now });
89
+ }
90
+ catch {
91
+ notCapable += 1;
92
+ continue;
93
+ }
43
94
  if (lease.type === 'ambiguous') {
44
95
  await input.hostLedger.appendEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', outcome: 'unknown', routeKind: lease.lease.routeKind ?? route.route.kind, attemptN: lease.lease.attemptN ?? 1, signature: 'worker_interrupted_during_dispatch', message: 'The notification worker ended after dispatch began; transport acceptance is unknown.', at: input.now });
45
96
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId: lease.lease.leaseId, session: route.session, at: input.now });
46
- leaseId = randomUUID();
47
- lease = await input.hostLedger.claimWakeDispatch({ attention, leaseId, leaseMs, session: route.session, at: input.now });
97
+ continue;
48
98
  }
49
99
  if (lease.type !== 'acquired')
50
100
  continue;
51
- const attempts = await input.hostLedger.listWakeAttempts({ attention, session: route.session, now: input.now });
101
+ let attempts;
102
+ try {
103
+ attempts = await input.hostLedger.listWakeAttempts({ attention, session: route.session, now: input.now });
104
+ }
105
+ catch {
106
+ notCapable += 1;
107
+ await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now }).catch(() => undefined);
108
+ continue;
109
+ }
110
+ if (attempts.some((attempt) => attempt.routeKind === route.route.kind && attempt.outcome === 'unknown')) {
111
+ await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now });
112
+ continue;
113
+ }
52
114
  const attemptN = attempts.reduce((highest, record) => Math.max(highest, record.attemptN ?? 0), 0) + 1;
53
- const request = { location: input.location, participant: membership.recipient, activity, route: { location: route.location, participant: route.participant, sessionId: route.session, channel: route.channel, kind: route.route.kind, address: { ...route.route.address }, updatedAt: route.updatedAt ?? 0 } };
115
+ const request = { location: input.location, participant: membership.recipient, activity, route: requestRoute };
54
116
  let outcome;
55
117
  const claim = await input.hostLedger.claimEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', leaseMs, now: input.now });
56
118
  if (claim.status !== 'acquired') {
57
119
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now });
120
+ if (claim.status === 'degraded')
121
+ notCapable += 1;
58
122
  continue;
59
123
  }
60
124
  attempted += 1;
@@ -69,8 +133,15 @@ export async function deliverPending(input) {
69
133
  catch (error) {
70
134
  outcome = { outcome: 'unknown', diagnostic: error instanceof Error ? error.message : String(error) };
71
135
  }
136
+ if (outcome.outcome === 'not-capable') {
137
+ await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', now: input.now }).catch(() => undefined);
138
+ await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now }).catch(() => undefined);
139
+ notCapable += 1;
140
+ continue;
141
+ }
72
142
  if (outcome.outcome === 'failed' && outcome.unavailable) {
73
- await input.transport.invalidate?.(request);
143
+ if (outcome.routeStale === true)
144
+ await retireWakeRouteFromArtifact(input.artifact, { location: route.location, participant: route.participant, sessionId: route.session });
74
145
  await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', now: input.now });
75
146
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now });
76
147
  failed += 1;
@@ -78,16 +149,19 @@ export async function deliverPending(input) {
78
149
  }
79
150
  await input.hostLedger.appendEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', outcome: outcome.outcome, routeKind: route.route.kind, attemptN: outcome.attemptN ?? attemptN, ...(outcome.outcome === 'accepted' && outcome.signature === undefined ? {} : outcome.outcome === 'accepted' ? { signature: outcome.signature } : outcome.outcome === 'failed' ? { message: outcome.message } : { diagnostic: outcome.diagnostic }), at: input.now });
80
151
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now });
81
- if (outcome.outcome === 'accepted')
152
+ if (outcome.outcome === 'accepted') {
82
153
  accepted += 1;
83
- else if (outcome.outcome === 'failed')
154
+ acceptedForAttention = true;
155
+ break;
156
+ }
157
+ if (outcome.outcome === 'failed')
84
158
  failed += 1;
85
159
  else
86
160
  unknown += 1;
87
161
  }
88
162
  }
89
163
  }
90
- return { attempted, accepted, failed, unknown };
164
+ return { attempted, accepted, failed, unknown, notCapable };
91
165
  }
92
166
  export function selectPendingWakeActivities(state, routes, attempts, now, graceMs, limit, delivery = deriveDeliveryModel(state)) {
93
167
  const selected = new Set();
@@ -95,10 +169,12 @@ export function selectPendingWakeActivities(state, routes, attempts, now, graceM
95
169
  for (const notification of delivery.pendingFor(membership)) {
96
170
  if (now - notification.item.at <= graceMs)
97
171
  continue;
172
+ if (attempts.some((attempt) => attempt.attention.actIndex === notification.item.index && nameKey(attempt.attention.recipient) === nameKey(membership) && attempt.outcome === 'accepted'))
173
+ continue;
98
174
  const eligible = routes.some((binding) => {
99
- if (binding.route === undefined || binding.participant.toLocaleLowerCase() !== membership.toLocaleLowerCase())
175
+ if (binding.route === undefined || nameKey(binding.participant) !== nameKey(membership))
100
176
  return false;
101
- const matching = attempts.filter((attempt) => attempt.session === binding.session && attempt.attention.squarePath === binding.location && attempt.attention.recipient.toLocaleLowerCase() === membership.toLocaleLowerCase() && attempt.attention.actIndex === notification.item.index);
177
+ const matching = attempts.filter((attempt) => attempt.session === binding.session && nameKey(attempt.attention.recipient) === nameKey(membership) && attempt.attention.actIndex === notification.item.index);
102
178
  return isWakeRouteAttemptable({ kind: binding.route.kind, updatedAt: binding.updatedAt ?? 0 }, matching);
103
179
  });
104
180
  if (eligible)
@@ -112,7 +188,14 @@ export async function sweepPending(input) {
112
188
  return sweepPendingFromState({ ...input, state });
113
189
  }
114
190
  export async function sweepPendingFromState(input) {
115
- const [bindings, records] = await Promise.all([input.hostLedger.listPresence({ location: input.location, scopes: ['user'], now: input.now }), input.hostLedger.listWakeAttempts({ now: input.now })]);
191
+ const bindings = (input.state.routes ?? []).map((route) => ({ location: input.location, participant: route.participant, session: route.sessionId, channel: route.channel, route: { kind: route.kind, address: route.address }, updatedAt: route.updatedAt }));
192
+ let records = [];
193
+ try {
194
+ records = await input.hostLedger.listWakeAttempts({ now: input.now });
195
+ }
196
+ catch {
197
+ records = [];
198
+ }
116
199
  const attempts = records.flatMap((record) => {
117
200
  const index = parseActivityId(record.activity);
118
201
  if (index === undefined || record.routeKind === undefined || typeof record.attemptN !== 'number')
@@ -35,6 +35,7 @@ export type WakeDispatchResult = {
35
35
  message: string;
36
36
  diagnostic?: unknown;
37
37
  retainRoute?: boolean;
38
+ routeStale?: boolean;
38
39
  } | {
39
40
  outcome: 'cancelled';
40
41
  };
@@ -116,7 +116,7 @@ export class FileHostLedgerPort {
116
116
  catch (error) {
117
117
  return { status: 'degraded', error };
118
118
  } }
119
- async releaseEvidence(i) { const f = this.file('user', 'evidence'), location = await canon(i.location), at = i.now ?? this.clock(); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, at)).filter(x => !(x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session && x.outcome === 'dispatching')))); }
119
+ async releaseEvidence(i) { const f = this.file('user', 'evidence'), location = await canon(i.location), at = i.now ?? this.clock(); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, at, true)).filter(x => !(x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session && x.outcome === 'dispatching')))); }
120
120
  async appendEvidence(i) { const f = this.file('user', 'evidence'), r = { ...i, location: await canon(i.location), at: i.at ?? this.clock(), v: 1 }; await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, r.at, true); const base = all.filter(x => k(x) === k(r)); const replaceable = new Set(base.filter(x => x.outcome === 'dispatching' || (r.attemptN !== undefined && x.attemptN === r.attemptN)).map(x => evidenceKey(x))); await write(f, [...all.filter(x => !(x.outcome === 'dispatching' && k(x) === k(r)) && !replaceable.has(evidenceKey(x))), r]); }); }
121
121
  async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location); return (await read(this.file('user', 'evidence'), i.now ?? this.clock())).filter(r => (!loc || r.location === loc) && (!i.participant || nameKey(r.participant) === nameKey(i.participant)) && (!i.session || r.session === i.session) && (!i.activity || r.activity === i.activity) && (!i.kind || r.kind === i.kind)); }
122
122
  async gcEvidence(i) { const f = this.file('user', 'evidence'); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock())).filter(r => r.at >= i.before))); }
@@ -135,21 +135,12 @@ export class FileHostLedgerPort {
135
135
  }
136
136
  const latest = new Map();
137
137
  for (const x of b) {
138
- const key = JSON.stringify([x.location, nameKey(x.participant)]);
138
+ const key = JSON.stringify([x.location, nameKey(x.participant), x.session]);
139
139
  const current = latest.get(key);
140
140
  if (current === undefined || ((x.updatedAt ?? 0) > (current.updatedAt ?? 0)))
141
141
  latest.set(key, x);
142
142
  }
143
143
  const winners = [...latest.values()];
144
- for (const x of b) {
145
- const winner = latest.get(JSON.stringify([x.location, nameKey(x.participant)]));
146
- if (winner === undefined || winner.session === x.session)
147
- continue;
148
- for (const scope of i.scopes ?? this.readable) {
149
- const remover = new FileHostLedgerPort({ userPath: this.user, localPath: this.local, writableScope: scope, readableScopes: [scope], now: this.clock });
150
- await remover.removePresence(x);
151
- }
152
- }
153
144
  const u = new FileHostLedgerPort({ userPath: this.user, localPath: this.local, writableScope: 'user', readableScopes: ['user'], now: this.clock });
154
145
  for (const x of winners) {
155
146
  const result = await u.ensurePresence(x);
package/dist/model.d.ts CHANGED
@@ -13,6 +13,8 @@ export interface WakeRoute {
13
13
  address: Record<string, string>;
14
14
  updatedAt: number;
15
15
  }
16
+ export interface ReceiverRoute extends WakeRoute {
17
+ }
16
18
  export type SquareErrorCode = 'invalid_name' | 'invalid_args' | 'unknown_participant' | 'not_joined' | 'already_joined' | 'already_done' | 'held' | 'capped' | 'throttled' | 'bell_quota' | 'behind' | 'io' | 'unavailable';
17
19
  export type InternalSquareErrorCode = SquareErrorCode | 'not_found' | 'cap_reached' | 'conflict' | 'pending_peer';
18
20
  export interface SquareErrorFacts {
@@ -82,6 +84,7 @@ export interface SquareState {
82
84
  preamble: string[];
83
85
  warmup: string[];
84
86
  acts: StoredAct[];
87
+ routes?: ReceiverRoute[];
85
88
  runtime: SquareRuntimeState;
86
89
  }
87
90
  export interface ActivitiesOptions {
package/dist/model.js CHANGED
@@ -40,7 +40,7 @@ export function findParticipantName(participants, name) {
40
40
  return participants.find((participant) => sameName(participant, name));
41
41
  }
42
42
  export function validateName(name) {
43
- if (!name || !/^[\p{L}\p{N}_-]+(?:\/[\p{L}\p{N}_-]+)*$/u.test(name)) {
44
- throw new SquareError('invalid_name', 'Invalid name: names must contain non-empty slash-separated segments using only Unicode letters, digits, hyphens, and underscores.');
43
+ if (!name || !/^(?:[[\p{L}\p{N}\p{M}_\x2D]--[\p{Variation_Selector}]]|\p{RGI_Emoji})+(?:\/(?:[[\p{L}\p{N}\p{M}_\x2D]--[\p{Variation_Selector}]]|\p{RGI_Emoji})+)*$/v.test(name)) {
44
+ throw new SquareError('invalid_name', 'Invalid name: names must contain non-empty slash-separated segments using Unicode letters, digits, marks, hyphens, underscores, or complete RGI emoji graphemes.');
45
45
  }
46
46
  }
@@ -2,6 +2,7 @@ import { planActNotifications, type WakeAdapter } from './delivery.js';
2
2
  import { type SquareState } from './model.js';
3
3
  import { matchesMentionTarget } from './runtime.js';
4
4
  import { type ActivityId } from './square-core.js';
5
+ import type { WakeTransportPort } from './ports.js';
5
6
  export type { PlannedNotification } from './delivery.js';
6
7
  export { planActNotifications, matchesMentionTarget };
7
8
  export { notificationMessageId } from './delivery.js';
@@ -16,7 +17,12 @@ interface ProcessNotificationOptions {
16
17
  env?: NodeJS.ProcessEnv;
17
18
  now?: () => number;
18
19
  }
20
+ export declare function defaultWakeAdapters(): Promise<WakeAdapter[]>;
21
+ export declare function createDefaultWakeTransport(hostLedger: import('./host-ledger.js').HostLedgerPort, clock: () => number, env?: NodeJS.ProcessEnv): Promise<WakeTransportPort>;
22
+ export declare function createWakeTransport(adapters: readonly WakeAdapter[], hostLedger: import('./host-ledger.js').HostLedgerPort, clock: () => number): WakeTransportPort;
19
23
  export declare function processActNotificationsOnce(squarePath: string, actIndex: number, opts?: ProcessNotificationOptions): Promise<import("./ports.js").DeliveryResult>;
24
+ /** Privileged hook fallback: sweep indexed squares plus the current cwd's local squares. */
25
+ export declare function sweepPrivilegedPending(cwd: string, env?: NodeJS.ProcessEnv, suppliedAdapters?: WakeAdapter[]): Promise<void>;
20
26
  export interface SweepPendingNotificationsOptions {
21
27
  env?: NodeJS.ProcessEnv;
22
28
  now?: number;
@@ -1,5 +1,6 @@
1
1
  import { setTimeout as sleep } from 'node:timers/promises';
2
2
  import path from 'node:path';
3
+ import fs from 'node:fs';
3
4
  import { planActNotifications, } from './delivery.js';
4
5
  import { SquareError } from './model.js';
5
6
  import { SLEEP_MS, matchesMentionTarget } from './runtime.js';
@@ -77,7 +78,7 @@ export async function waitForDeliveredNotification(squarePath, name, ref, opts =
77
78
  }
78
79
  return false;
79
80
  }
80
- async function defaultWakeAdapters() {
81
+ export async function defaultWakeAdapters() {
81
82
  const adapters = [];
82
83
  try {
83
84
  const { CodexQueueAdapter } = await import('./codex-queue.js');
@@ -95,12 +96,30 @@ async function defaultWakeAdapters() {
95
96
  }
96
97
  return adapters;
97
98
  }
98
- function createWakeTransport(adapters, hostLedger, clock) {
99
+ export async function createDefaultWakeTransport(hostLedger, clock, env = process.env) {
100
+ const adapters = await defaultWakeAdapters();
101
+ return createWakeTransport(env.SQUARE_DISABLE_PASEO_WAKE === '1' ? adapters.filter((adapter) => adapter.kind !== 'paseo') : adapters, hostLedger, clock);
102
+ }
103
+ export function createWakeTransport(adapters, hostLedger, clock) {
99
104
  return {
105
+ probe: async (route) => {
106
+ const adapter = adapters.find((candidate) => candidate.kind === route.kind);
107
+ if (adapter === undefined)
108
+ return { outcome: 'not-capable', diagnostic: `no adapter for ${route.kind}` };
109
+ const probe = adapter.probe;
110
+ if (probe === undefined)
111
+ return true;
112
+ try {
113
+ return await probe.call(adapter, route.address);
114
+ }
115
+ catch (error) {
116
+ return { outcome: 'not-capable', diagnostic: error instanceof Error ? error.message : String(error) };
117
+ }
118
+ },
100
119
  attempt: async (request, _timeoutMs) => {
101
120
  const adapter = adapters.find((candidate) => candidate.kind === request.route.kind);
102
121
  if (adapter === undefined)
103
- return { outcome: 'failed', message: 'wake adapter unavailable' };
122
+ return { outcome: 'not-capable', diagnostic: `no adapter for ${request.route.kind}` };
104
123
  try {
105
124
  const result = await adapter.dispatch(request.route.address, renderWakePayload(request), async () => true);
106
125
  if (result.outcome === 'accepted')
@@ -108,7 +127,7 @@ function createWakeTransport(adapters, hostLedger, clock) {
108
127
  if (result.outcome === 'failed')
109
128
  return { outcome: 'failed', message: result.message };
110
129
  if (result.outcome === 'unavailable')
111
- return { outcome: 'failed', message: result.message, unavailable: true };
130
+ return { outcome: 'failed', message: result.message, unavailable: true, ...(result.retainRoute === true ? { retainRoute: true } : {}), ...(result.routeStale === true ? { routeStale: true } : {}) };
112
131
  if (result.outcome === 'unknown')
113
132
  return { outcome: 'unknown', diagnostic: result.message };
114
133
  return { outcome: 'unknown', diagnostic: 'wake dispatch cancelled' };
@@ -117,15 +136,6 @@ function createWakeTransport(adapters, hostLedger, clock) {
117
136
  return { outcome: 'unknown', diagnostic: error instanceof Error ? error.message : String(error) };
118
137
  }
119
138
  },
120
- invalidate: async (request) => {
121
- await hostLedger.ensurePresence({
122
- location: request.route.location,
123
- participant: request.route.participant,
124
- session: request.route.sessionId,
125
- channel: request.route.channel,
126
- updatedAt: clock(),
127
- }, 'user');
128
- },
129
139
  };
130
140
  }
131
141
  export async function processActNotificationsOnce(squarePath, actIndex, opts = {}) {
@@ -140,12 +150,55 @@ export async function processActNotificationsOnce(squarePath, actIndex, opts = {
140
150
  try {
141
151
  const adapters = opts.adapters ?? await defaultWakeAdapters();
142
152
  const transport = createWakeTransport(adapters, hostLedger, now);
143
- return await deliverPending({ artifact: square.artifact, hostLedger, transport, location: squarePath, activity: actIndex, timeoutMs: Number(env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? 5000), now: now() });
153
+ try {
154
+ return await deliverPending({ artifact: square.artifact, hostLedger, transport, location: squarePath, activity: actIndex, timeoutMs: Number(env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? 5000), now: now() });
155
+ }
156
+ catch {
157
+ return { attempted: 0, accepted: 0, failed: 0, unknown: 0, notCapable: 1 };
158
+ }
144
159
  }
145
160
  finally {
146
161
  await closeOpenSquare(square);
147
162
  }
148
163
  }
164
+ /** Privileged hook fallback: sweep indexed squares plus the current cwd's local squares. */
165
+ export async function sweepPrivilegedPending(cwd, env = process.env, suppliedAdapters) {
166
+ const root = env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(env.SQUARE_REGISTRY);
167
+ const hostLedger = createHostLedgerPort({ userPath: env.SQUARE_HOST_LEDGER_USER ?? root, localPath: env.SQUARE_HOST_LEDGER_LOCAL ?? root, readableScopes: ['user'], writableScope: 'user' });
168
+ let indexed = [];
169
+ try {
170
+ indexed = await hostLedger.listPresence({ scopes: ['user'], now: Date.now() });
171
+ }
172
+ catch { /* capability is best effort */ }
173
+ const paths = new Set(indexed.map((binding) => binding.location));
174
+ try {
175
+ for (const entry of await fs.promises.readdir(path.join(cwd, '.square'))) {
176
+ if (entry.endsWith('.square'))
177
+ paths.add(path.join(cwd, '.square', entry));
178
+ }
179
+ }
180
+ catch { /* no local square directory */ }
181
+ const adapters = suppliedAdapters ?? await defaultWakeAdapters();
182
+ for (const squarePath of paths) {
183
+ try {
184
+ const square = await openSquare(squarePath, { hostLedger, env });
185
+ try {
186
+ const snapshot = await square.artifact.read();
187
+ await hostLedger.reconcileBinding({ artifact: square.artifact, scopes: ['user'], now: Date.now() }).catch(() => undefined);
188
+ const limit = Number.parseInt(env.SQUARE_NOTIFY_SWEEP_LIMIT ?? '8', 10);
189
+ const graceMs = 0;
190
+ const selected = await sweepPending({ artifact: square.artifact, hostLedger, location: squarePath, now: Date.now(), graceMs, limit: Number.isFinite(limit) && limit > 0 ? limit : 8 }).catch(() => []);
191
+ const transport = createWakeTransport(adapters, hostLedger, Date.now);
192
+ for (const actIndex of selected)
193
+ await deliverPending({ artifact: square.artifact, hostLedger, transport, location: squarePath, activity: actIndex, timeoutMs: Number(env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? 5000), now: Date.now() }).catch(() => undefined);
194
+ }
195
+ finally {
196
+ await closeOpenSquare(square);
197
+ }
198
+ }
199
+ catch { /* stale index entries are ignored by the hook */ }
200
+ }
201
+ }
149
202
  /** Select sweep candidates from one frozen snapshot and one delivery replay. */
150
203
  export async function pendingNotificationSweepFromState(squarePath, state, now, env, limit, deriveDelivery) {
151
204
  const ledger = createHostLedgerPort({ userPath: env.SQUARE_HOST_LEDGER_USER, writableScope: 'user', readableScopes: ['user'] });
@@ -1,10 +1,11 @@
1
- import type { HostLedgerPort, SquareArtifactPort } from './ports.js';
1
+ import type { HostLedgerPort, SquareArtifactPort, WakeTransportPort } from './ports.js';
2
2
  /** Private binding assembled by storage and consumed by the four concerns. */
3
3
  export interface OpenSquare {
4
4
  readonly artifact: SquareArtifactPort;
5
5
  readonly clock: () => number;
6
6
  readonly location: string;
7
7
  readonly hostLedger?: HostLedgerPort;
8
+ readonly wakeTransport?: WakeTransportPort;
8
9
  readonly env?: NodeJS.ProcessEnv;
9
10
  }
10
11
  /** Package-private lifecycle boundary for bound squares. */
@@ -37,6 +37,7 @@ export class PaseoAdapter {
37
37
  outcome: 'unavailable',
38
38
  signature: 'invalid_address',
39
39
  message: 'Paseo route has no agent id.',
40
+ routeStale: true,
40
41
  diagnostic: diagnostic('selection', address, 'invalid_address'),
41
42
  };
42
43
  }
@@ -58,6 +59,7 @@ export class PaseoAdapter {
58
59
  message: agent === undefined ? 'The registered Paseo agent was not found.' : 'The registered Paseo agent is not idle.',
59
60
  diagnostic: diagnostic('selection', address, agent === undefined ? 'not_found' : 'not_idle'),
60
61
  ...(agent === undefined ? {} : { retainRoute: true }),
62
+ ...(agent === undefined ? { routeStale: true } : {}),
61
63
  };
62
64
  }
63
65
  if (!(await (this.opts.waitForBoundary ?? waitForPaseoWakeBoundary)(agent))) {
package/dist/ports.d.ts CHANGED
@@ -17,6 +17,11 @@ export interface SquareArtifactPort {
17
17
  }
18
18
  /** Capability-neutral wake transport. Unused by this Contract's activity operations. */
19
19
  export interface WakeTransportPort {
20
+ /** Capability check that performs no external wake and writes no evidence. */
21
+ probe?(route: WakeRoute): Promise<boolean | {
22
+ readonly outcome: 'not-capable';
23
+ readonly diagnostic?: string;
24
+ }>;
20
25
  attempt(request: WakeRequest, timeoutMs: number): Promise<WakeOutcome>;
21
26
  /** Optional route retirement supplied by the concrete executor adapter. */
22
27
  invalidate?(request: WakeRequest): Promise<void>;
@@ -65,6 +70,11 @@ export type WakeOutcome = {
65
70
  readonly message?: string;
66
71
  readonly attemptN?: number;
67
72
  readonly unavailable?: boolean;
73
+ readonly retainRoute?: boolean;
74
+ readonly routeStale?: boolean;
75
+ } | {
76
+ readonly outcome: 'not-capable';
77
+ readonly diagnostic?: string;
68
78
  } | {
69
79
  readonly outcome: 'unknown';
70
80
  readonly diagnostic?: string;
@@ -98,6 +108,7 @@ export interface DeliveryResult {
98
108
  readonly accepted: number;
99
109
  readonly failed: number;
100
110
  readonly unknown: number;
111
+ readonly notCapable: number;
101
112
  }
102
113
  export interface DeliverPendingInput {
103
114
  readonly artifact: SquareArtifactPort;
package/dist/presence.js CHANGED
@@ -5,7 +5,7 @@ import { recordObservation } from './runtime.js';
5
5
  import { catchUp as actionCatchUp } from './square-actions.js';
6
6
  function operationContext(square) {
7
7
  return 'artifact' in square
8
- ? { artifact: square.artifact, clock: square.clock }
8
+ ? { artifact: square.artifact, clock: square.clock, location: square.location, hostLedger: square.hostLedger, wakeTransport: square.wakeTransport, env: square.env }
9
9
  : { artifact: square.cell, clock: square.clock };
10
10
  }
11
11
  export async function catchUp(square, name, options = {}, deriveDelivery = deriveDeliveryModel) {
package/dist/registry.js CHANGED
@@ -26,7 +26,7 @@ async function writePresence(sessionId, name, squarePath, options, done, scope =
26
26
  return; const env = options.env ?? process.env; const channel = options.channel ?? 'unknown'; const port = ledger(env, scope); const location = await canonicalSquarePath(squarePath); if (done)
27
27
  await port.removePresence({ location, participant: name, session: sessionId, channel });
28
28
  else
29
- await port.ensurePresence({ location, participant: name, session: sessionId, channel, route: options.route, updatedAt: options.at ?? Date.now() }); }
29
+ await port.ensurePresence({ location, participant: name, session: sessionId, channel, updatedAt: options.at ?? Date.now() }); }
30
30
  export function recordJoin(sessionId, name, squarePath, options = {}) { return writePresence(sessionId, name, squarePath, options, false); }
31
31
  export async function recordDone(sessionId, name, squarePath, options = {}) {
32
32
  await writePresence(sessionId, name, squarePath, options, true);
@@ -64,17 +64,16 @@ function addLocalSession(identities, sessionId, channel, child, paseoAgentId) {
64
64
  export function localSessionIdentities(env = process.env) { const paseoAgentId = env.PASEO_AGENT_ID?.trim() || undefined; const identities = []; for (const source of LOCAL_SESSION_SOURCES)
65
65
  addLocalSession(identities, env[source.variable]?.trim(), source.channel, source.child !== undefined && env[source.child] === '1', paseoAgentId); addLocalSession(identities, paseoAgentId, 'paseo', false, paseoAgentId); return identities; }
66
66
  export function hasAutomaticDeliveryIdentity(env = process.env) { return localSessionIdentities(env).length > 0; }
67
- function callableRoute(sessionId, channel, env) { if (channel === 'codex' && env.CODEX_THREAD_ID?.trim() === sessionId)
68
- return { kind: 'codex-queue', address: { threadId: sessionId } }; if (channel === 'paseo' && env.PASEO_AGENT_ID?.trim() === sessionId)
69
- return { kind: 'paseo', address: { agentId: sessionId } }; return undefined; }
70
- export async function recordLocalJoin(name, squarePath, env = process.env) { const at = Date.now(); const identities = localSessionIdentities(env); const current = await lookupParticipant(squarePath, name, at, env); for (const binding of current)
71
- await recordDone(binding.sessionId, binding.name, binding.squarePath, { channel: binding.channel, at, env }); for (const identity of identities)
72
- await recordJoin(identity.sessionId, name, squarePath, { ...identity, at, env }); }
73
- export async function recordLocalDone(name, squarePath, env = process.env) { const at = Date.now(); const current = await lookupParticipant(squarePath, name, at, env); for (const binding of current)
67
+ export async function recordLocalJoin(name, squarePath, env = process.env) { const at = Date.now(); const identities = localSessionIdentities(env); const current = await lookupParticipant(squarePath, name, at, env); for (const identity of identities) {
68
+ for (const binding of current.filter((item) => item.sessionId === identity.sessionId))
69
+ await recordDone(binding.sessionId, binding.name, binding.squarePath, { channel: binding.channel, at, env });
70
+ await recordJoin(identity.sessionId, name, squarePath, { ...identity, at, env });
71
+ } }
72
+ export async function recordLocalDone(name, squarePath, env = process.env) { const at = Date.now(); const identities = new Set(localSessionIdentities(env).map((identity) => identity.sessionId)); const current = (await lookupParticipant(squarePath, name, at, env)).filter((binding) => identities.has(binding.sessionId)); for (const binding of current)
74
73
  await recordDone(binding.sessionId, binding.name, binding.squarePath, { channel: binding.channel, at, env }); }
75
- export async function recordSessionJoin(sessionId, name, squarePath, channel, env = process.env) { const at = Date.now(); const current = await lookupParticipant(squarePath, name, at, env); for (const binding of current) {
74
+ export async function recordSessionJoin(sessionId, name, squarePath, channel, env = process.env) { const at = Date.now(); const current = (await lookupParticipant(squarePath, name, at, env)).filter((binding) => binding.sessionId === sessionId); for (const binding of current) {
76
75
  await recordDone(binding.sessionId, binding.name, binding.squarePath, { channel: binding.channel, at, env });
77
76
  await writePresence(binding.sessionId, binding.name, binding.squarePath, { channel: binding.channel, at, env }, true, 'user');
78
- } const route = callableRoute(sessionId, channel, env); await recordJoin(sessionId, name, squarePath, { channel, at, env }); await writePresence(sessionId, name, squarePath, { channel, route, at, env }, false, 'user'); return sessionId; }
77
+ } await recordJoin(sessionId, name, squarePath, { channel, at, env }); await writePresence(sessionId, name, squarePath, { channel, at, env }, false, 'user'); return sessionId; }
79
78
  export async function recordSessionDone(sessionId, name, squarePath, channel, env = process.env) { const canonicalPath = await canonicalSquarePath(squarePath); const binding = (await lookupSessionBindings(sessionId, Date.now(), env)).find((item) => item.squarePath === canonicalPath && sameName(item.name, name) && item.channel === channel); if (binding === undefined)
80
79
  return false; const options = { channel, at: Date.now(), env }; await recordDone(sessionId, binding.name, binding.squarePath, options); await writePresence(sessionId, binding.name, binding.squarePath, options, true, 'user'); return true; }
package/dist/routes.d.ts CHANGED
@@ -1,7 +1,26 @@
1
- import { type WakeRoute } from './model.js';
1
+ import { type WakeRoute, type WakeRouteKind } from './model.js';
2
2
  export { WAKE_ROUTE_KINDS } from './model.js';
3
3
  export type { WakeRoute, WakeRouteKind } from './model.js';
4
4
  export declare const ROUTE_FRESH_MS: number;
5
+ export type WakeBoundaryProvider = 'codex' | 'claude' | 'opencode' | 'pi' | 'paseo';
6
+ export interface WakeBoundary {
7
+ readonly location: string;
8
+ readonly participant: string;
9
+ readonly sessionId: string;
10
+ readonly provider: WakeBoundaryProvider;
11
+ }
12
+ export interface WakeRouteCapabilities {
13
+ readonly canUse: (kind: WakeRouteKind, address: Readonly<Record<string, string>>) => boolean;
14
+ }
15
+ export declare function defaultWakeRouteCapabilities(hostLedger?: import('./host-ledger.js').HostLedgerPort): Promise<WakeRouteCapabilities>;
16
+ /** Pure, ordered route precedence. Capability predicates own adapter and scope checks. */
17
+ export declare function selectPrimaryWakeRoute(input: {
18
+ readonly boundary: WakeBoundary;
19
+ readonly env: NodeJS.ProcessEnv;
20
+ readonly capabilities: WakeRouteCapabilities;
21
+ }): Omit<WakeRoute, 'updatedAt'> | undefined;
22
+ export declare function routeIdentityKey(route: Pick<WakeRoute, 'location' | 'participant' | 'sessionId'>, location?: string): string;
23
+ export declare function resolvePrimaryWakeRoute(boundary: WakeBoundary, env: NodeJS.ProcessEnv, capabilities: WakeRouteCapabilities): Omit<WakeRoute, 'updatedAt'> | undefined;
5
24
  export declare function readWakeRoutes(opts?: {
6
25
  location?: string;
7
26
  participant?: string;
@@ -14,7 +33,12 @@ export declare function upsertWakeRoute(route: Omit<WakeRoute, 'updatedAt'>, opt
14
33
  at?: number;
15
34
  env?: NodeJS.ProcessEnv;
16
35
  }): Promise<void>;
36
+ export declare function publishWakeRoute(artifact: import('./ports.js').SquareArtifactPort, route: Omit<WakeRoute, 'updatedAt'>, opts?: {
37
+ at?: number;
38
+ }): Promise<void>;
17
39
  export declare function retireWakeRoute(route: WakeRoute, opts?: {
18
40
  at?: number;
19
41
  env?: NodeJS.ProcessEnv;
20
42
  }): Promise<void>;
43
+ export declare function retireWakeRouteFromArtifact(artifact: import('./ports.js').SquareArtifactPort, route: Pick<WakeRoute, 'location' | 'participant' | 'sessionId'>): Promise<void>;
44
+ export declare function canonicalRouteLocation(location: string): Promise<string>;
package/dist/routes.js CHANGED
@@ -1,9 +1,110 @@
1
- import os from 'node:os';
1
+ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createHostLedgerPort } from './host-ledger-file-adapter.js';
3
+ import { nameKey } from './model.js';
4
+ import { openSquare } from './square-file-adapter.js';
5
+ import { closeOpenSquare } from './open-square.js';
4
6
  export { WAKE_ROUTE_KINDS } from './model.js';
5
7
  export const ROUTE_FRESH_MS = 24 * 60 * 60 * 1000;
6
- function ledger(env) { return createHostLedgerPort({ userPath: env.SQUARE_HOST_LEDGER_USER ?? path.join(os.homedir(), '.square', 'host-ledger'), writableScope: 'user', readableScopes: ['user'] }); }
7
- export async function readWakeRoutes(opts = {}) { const now = opts.now ?? Date.now(); const records = await ledger(opts.env ?? process.env).listPresence({ location: opts.location, participant: opts.participant, session: opts.sessionId, now, scopes: ['user'] }); return records.flatMap((record) => record.route === undefined || (opts.freshOnly === true && now - (record.updatedAt ?? 0) >= ROUTE_FRESH_MS) ? [] : [{ location: record.location, participant: record.participant, sessionId: record.session, channel: record.channel, kind: record.route.kind, address: { ...record.route.address }, updatedAt: record.updatedAt ?? 0 }]); }
8
- export async function upsertWakeRoute(route, opts = {}) { await ledger(opts.env ?? process.env).ensurePresence({ location: route.location, participant: route.participant, session: route.sessionId, channel: route.channel, route: { kind: route.kind, address: route.address }, updatedAt: opts.at ?? Date.now() }); }
9
- export async function retireWakeRoute(route, opts = {}) { await ledger(opts.env ?? process.env).ensurePresence({ location: route.location, participant: route.participant, session: route.sessionId, channel: route.channel, updatedAt: opts.at ?? Date.now() }); }
8
+ export async function defaultWakeRouteCapabilities(hostLedger) {
9
+ let userCapable = hostLedger !== undefined;
10
+ if (hostLedger !== undefined) {
11
+ try {
12
+ await hostLedger.listPresence({ scopes: ['user'], now: Date.now() });
13
+ }
14
+ catch {
15
+ userCapable = false;
16
+ }
17
+ }
18
+ const available = new Set();
19
+ try {
20
+ const { CodexQueueAdapter } = await import('./codex-queue.js');
21
+ available.add(new CodexQueueAdapter().kind);
22
+ }
23
+ catch { /* optional */ }
24
+ try {
25
+ const { PaseoAdapter } = await import('./paseo-delivery.js');
26
+ available.add(new PaseoAdapter().kind);
27
+ }
28
+ catch { /* optional */ }
29
+ return { canUse: (kind, address) => userCapable && available.has(kind) && Object.values(address).every((value) => value.trim() !== '') };
30
+ }
31
+ function nativeCandidate(boundary) {
32
+ if (boundary.provider === 'codex')
33
+ return { kind: 'codex-queue', address: { threadId: boundary.sessionId } };
34
+ if (boundary.provider === 'claude')
35
+ return { kind: 'claude-native', address: { sessionId: boundary.sessionId } };
36
+ if (boundary.provider === 'opencode')
37
+ return { kind: 'opencode-server', address: { sessionId: boundary.sessionId } };
38
+ if (boundary.provider === 'pi')
39
+ return { kind: 'pi-extension', address: { sessionId: boundary.sessionId } };
40
+ return undefined;
41
+ }
42
+ /** Pure, ordered route precedence. Capability predicates own adapter and scope checks. */
43
+ export function selectPrimaryWakeRoute(input) {
44
+ const { boundary, env, capabilities } = input;
45
+ const paseoAgentId = env.PASEO_AGENT_ID?.trim();
46
+ const candidates = [];
47
+ if (paseoAgentId)
48
+ candidates.push({ kind: 'paseo', address: { agentId: paseoAgentId } });
49
+ const native = nativeCandidate(boundary);
50
+ if (native)
51
+ candidates.push(native);
52
+ const chosen = candidates.find((candidate) => Object.values(candidate.address).every((value) => value.trim() !== '') && capabilities.canUse(candidate.kind, candidate.address));
53
+ return chosen === undefined ? undefined : { location: boundary.location, participant: boundary.participant, sessionId: boundary.sessionId, channel: boundary.provider === 'paseo' ? 'paseo' : boundary.provider === 'claude' ? 'claude-code' : boundary.provider, ...chosen };
54
+ }
55
+ export function routeIdentityKey(route, location = route.location) {
56
+ return JSON.stringify([location, nameKey(route.participant), route.sessionId]);
57
+ }
58
+ export function resolvePrimaryWakeRoute(boundary, env, capabilities) {
59
+ return selectPrimaryWakeRoute({ boundary, env, capabilities });
60
+ }
61
+ async function withArtifact(location, fn) {
62
+ if (location === undefined)
63
+ return undefined;
64
+ try {
65
+ await fs.promises.access(location);
66
+ const square = await openSquare(location);
67
+ try {
68
+ return await fn(square);
69
+ }
70
+ finally {
71
+ await closeOpenSquare(square);
72
+ }
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ export async function readWakeRoutes(opts = {}) {
79
+ const now = opts.now ?? Date.now();
80
+ const canonicalLocation = opts.location === undefined ? undefined : await canonicalRouteLocation(opts.location);
81
+ const routes = await withArtifact(canonicalLocation, async (square) => (await square.artifact.read()).state.routes ?? []) ?? [];
82
+ const filtered = routes.filter((route) => (opts.participant === undefined || nameKey(route.participant) === nameKey(opts.participant)) && (opts.sessionId === undefined || route.sessionId === opts.sessionId) && (!opts.freshOnly || now - route.updatedAt < ROUTE_FRESH_MS));
83
+ const canonicalized = await Promise.all(filtered.map(async (route) => ({ ...route, location: await canonicalRouteLocation(route.location), address: { ...route.address } })));
84
+ return canonicalLocation === undefined ? canonicalized : canonicalized.filter((route) => route.location === canonicalLocation);
85
+ }
86
+ export async function upsertWakeRoute(route, opts = {}) {
87
+ const location = await canonicalRouteLocation(route.location);
88
+ await withArtifact(location, async (square) => publishWakeRoute(square.artifact, { ...route, location }, opts));
89
+ }
90
+ export async function publishWakeRoute(artifact, route, opts = {}) {
91
+ const location = await canonicalRouteLocation(route.location);
92
+ await artifact.transact((state) => ({ state: { ...state, routes: [...(state.routes ?? []).filter((item) => routeIdentityKey(item) !== routeIdentityKey({ ...route, location })), { ...route, location, participant: route.participant, updatedAt: opts.at ?? Date.now() }] }, result: undefined }));
93
+ }
94
+ export async function retireWakeRoute(route, opts = {}) {
95
+ const location = await canonicalRouteLocation(route.location);
96
+ await withArtifact(location, async (square) => retireWakeRouteFromArtifact(square.artifact, { ...route, location }));
97
+ }
98
+ export async function retireWakeRouteFromArtifact(artifact, route) {
99
+ const location = await canonicalRouteLocation(route.location);
100
+ await artifact.transact((state) => ({ state: { ...state, routes: (state.routes ?? []).filter((item) => routeIdentityKey(item) !== routeIdentityKey({ ...route, location })) }, result: undefined }));
101
+ }
102
+ export async function canonicalRouteLocation(location) {
103
+ const absolute = path.resolve(location);
104
+ try {
105
+ return await fs.promises.realpath(absolute);
106
+ }
107
+ catch {
108
+ return absolute;
109
+ }
110
+ }
@@ -8,6 +8,7 @@ export interface OperationContext {
8
8
  readonly clock: () => number;
9
9
  readonly location?: string;
10
10
  readonly hostLedger?: HostLedgerPort;
11
+ readonly wakeTransport?: import('./ports.js').WakeTransportPort;
11
12
  readonly env?: NodeJS.ProcessEnv;
12
13
  }
13
14
  export declare function catchUp(square: OperationContext, name: string, options?: CatchOptions, project?: (state: SquareState) => CatchProjection): Promise<CatchResult>;
@@ -2,7 +2,9 @@ import { extractMentions, formatActivityId, parseActivityId } from './square-cor
2
2
  import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, decideAct, decideImplicitJoin, decideJoin } from './decisions.js';
3
3
  import { SquareError } from './model.js';
4
4
  import { participantIdentity } from './participant-identity.js';
5
+ import { deliverPending } from './delivery-operations.js';
5
6
  import { decideCatch } from './catch-decisions.js';
7
+ import { publishWakeRoute, retireWakeRouteFromArtifact, resolvePrimaryWakeRoute, defaultWakeRouteCapabilities } from './routes.js';
6
8
  function processIdentity(env) {
7
9
  const choices = [
8
10
  [env.CLAUDE_CODE_SESSION_ID, 'claude-code'], [env.CODEX_THREAD_ID, 'codex'],
@@ -11,6 +13,25 @@ function processIdentity(env) {
11
13
  const found = choices.find(([session]) => session?.trim());
12
14
  return found === undefined ? { session: `process:${process.pid}`, channel: 'unknown' } : { session: found[0].trim(), channel: found[1] };
13
15
  }
16
+ async function publishIdentityRoute(context, participant) {
17
+ if (context.location === undefined || context.location === 'memory')
18
+ return;
19
+ const identity = processIdentity(context.env ?? process.env);
20
+ if (context.hostLedger === undefined)
21
+ return;
22
+ const provider = identity.channel === 'claude-code' ? 'claude' : identity.channel === 'opencode' ? 'opencode' : identity.channel === 'pi' ? 'pi' : identity.channel === 'paseo' ? 'paseo' : 'codex';
23
+ const capabilities = await defaultWakeRouteCapabilities(context.hostLedger);
24
+ const route = await resolvePrimaryWakeRoute({ location: context.location, participant, sessionId: identity.session, provider }, context.env ?? process.env, capabilities);
25
+ if (route === undefined)
26
+ return;
27
+ await publishWakeRoute(context.artifact, route, { at: context.clock() }).catch(() => undefined);
28
+ }
29
+ async function retireIdentityRoute(context, participant) {
30
+ if (context.location === undefined || context.location === 'memory')
31
+ return;
32
+ const identity = processIdentity(context.env ?? process.env);
33
+ await retireWakeRouteFromArtifact(context.artifact, { location: context.location, participant, sessionId: identity.session }).catch(() => undefined);
34
+ }
14
35
  /** Presence is best effort and runs only after the artifact mutation commits. */
15
36
  async function ensureLocalPresence(context, participant) {
16
37
  if (context.hostLedger === undefined || context.location === undefined || context.location === 'memory')
@@ -48,6 +69,7 @@ export async function catchUp(square, name, options = {}, project) {
48
69
  return { ...(decision.changed ? { state } : {}), result: { version, decision } };
49
70
  });
50
71
  await ensureLocalPresence(square, name);
72
+ await publishIdentityRoute(square, name);
51
73
  if (attempt.decision.delivered.length > 0 || idle === 0) {
52
74
  return {
53
75
  activities: attempt.decision.delivered.map((activity) => exposeCaught(activity, attempt.decision.perceptions.get(activity.index) ?? 'full')),
@@ -103,6 +125,7 @@ export async function join(square, name) {
103
125
  return { state, result: { name: decision.joinedName, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
104
126
  });
105
127
  await ensureLocalPresence(square, committed.name);
128
+ await publishIdentityRoute(square, committed.name);
106
129
  return { name: committed.name, activity: committed.stored === null ? null : exposeActivity(committed.stored) };
107
130
  }
108
131
  export async function implicitJoin(square, name) {
@@ -114,6 +137,10 @@ export async function implicitJoin(square, name) {
114
137
  return { state, result: { name: decision.joinedName, state: decision.state, stored: committedActivity(storeActs(state, [decision.joinAct]), 'join') } };
115
138
  });
116
139
  await ensureLocalPresence(square, committed.name);
140
+ if (committed.state === 'done')
141
+ await retireIdentityRoute(square, committed.name);
142
+ else
143
+ await publishIdentityRoute(square, committed.name);
117
144
  return { name: committed.name, state: committed.state, activity: committed.stored === null ? null : exposeActivity(committed.stored) };
118
145
  }
119
146
  export async function express(square, name, body, options = {}) {
@@ -139,7 +166,15 @@ export async function express(square, name, body, options = {}) {
139
166
  return { state, result: { stored } };
140
167
  });
141
168
  await ensureLocalPresence(square, name);
142
- return { activity: exposeActivity(committed.stored) };
169
+ await publishIdentityRoute(square, name);
170
+ let delivery;
171
+ if (square.wakeTransport !== undefined && square.hostLedger !== undefined && square.location !== undefined && square.location !== 'memory') {
172
+ delivery = await deliverPending({ artifact: square.artifact, hostLedger: square.hostLedger, transport: square.wakeTransport, location: square.location, activity: committed.stored.index, now }).catch(() => ({ attempted: 0, accepted: 0, failed: 0, unknown: 0, notCapable: 1 }));
173
+ }
174
+ else {
175
+ delivery = { attempted: 0, accepted: 0, failed: 0, unknown: 0, notCapable: 1 };
176
+ }
177
+ return { activity: exposeActivity(committed.stored), delivery };
143
178
  }
144
179
  async function landListenerChange(square, verb, actor, target) {
145
180
  const now = square.clock();
@@ -160,6 +195,8 @@ async function landCore(square, verb, actor, body = '') {
160
195
  const act = verb === 'done' ? coreDone(state, actor, body, now) : verb === 'hold' ? coreHold(state, actor, body, now) : coreResume(state, actor, now);
161
196
  return { state, result: committedActivity(storeActs(state, [act]), verb) };
162
197
  });
198
+ if (verb === 'done')
199
+ await retireIdentityRoute(square, actor);
163
200
  return { activity: exposeActivity(stored) };
164
201
  }
165
202
  export function done(square, name, body = '') { return landCore(square, 'done', name, body); }
@@ -1,5 +1,5 @@
1
1
  import type { ActivityId, Perception, Reach } from './square-core.js';
2
- import type { HostLedgerPort } from './ports.js';
2
+ import type { HostLedgerPort, WakeTransportPort, DeliveryResult } from './ports.js';
3
3
  export interface Activity {
4
4
  readonly id: ActivityId;
5
5
  readonly at: number;
@@ -20,6 +20,7 @@ export interface ExpressOptions {
20
20
  }
21
21
  export interface ExpressResult {
22
22
  readonly activity: Activity;
23
+ readonly delivery?: DeliveryResult;
23
24
  }
24
25
  export interface ListenerChangeResult {
25
26
  readonly activity: Activity | null;
@@ -68,6 +69,7 @@ export type SquareSource = {
68
69
  export interface OpenOptions {
69
70
  clock?: () => number;
70
71
  hostLedger?: HostLedgerPort;
72
+ wakeTransport?: WakeTransportPort;
71
73
  env?: NodeJS.ProcessEnv;
72
74
  }
73
75
  export interface SquareAtInput extends SquareSource, OpenOptions {
@@ -78,6 +80,7 @@ export interface SquareBuildInput extends SquareSource {
78
80
  throttlePerMinute?: number;
79
81
  clock?: () => number;
80
82
  hostLedger?: HostLedgerPort;
83
+ wakeTransport?: WakeTransportPort;
81
84
  env?: NodeJS.ProcessEnv;
82
85
  }
83
86
  export interface Participant {
@@ -11,9 +11,10 @@ export interface SquareBuildOptions {
11
11
  throttlePerMinute?: number;
12
12
  clock?: () => number;
13
13
  hostLedger?: HostLedgerPort;
14
+ wakeTransport?: import('./ports.js').WakeTransportPort;
14
15
  env?: NodeJS.ProcessEnv;
15
16
  }
16
- export declare function openSquare(squarePath: string, options?: Pick<SquareBuildOptions, 'clock' | 'hostLedger' | 'env'>): Promise<OpenSquare>;
17
+ export declare function openSquare(squarePath: string, options?: Pick<SquareBuildOptions, 'clock' | 'hostLedger' | 'wakeTransport' | 'env'>): Promise<OpenSquare>;
17
18
  export declare function probeSquare(squarePath: string): Promise<OpenSquare | undefined>;
18
19
  export declare function buildSquare(squarePath: string, options: SquareBuildOptions): Promise<OpenSquare>;
19
20
  export declare function buildMemorySquare(options: SquareBuildOptions): OpenSquare;
@@ -39,6 +39,7 @@ export async function openSquare(squarePath, options = {}) {
39
39
  userPath: env.SQUARE_HOST_LEDGER_USER ?? ledgerRoot,
40
40
  localPath: env.SQUARE_HOST_LEDGER_LOCAL ?? ledgerRoot,
41
41
  }),
42
+ wakeTransport: options.wakeTransport,
42
43
  };
43
44
  }
44
45
  catch (error) {
@@ -77,7 +78,7 @@ export function buildMemorySquare(options) {
77
78
  hardCap: options.hardCap ?? null,
78
79
  ...(options.throttlePerMinute === undefined ? {} : { throttlePerMinute: options.throttlePerMinute }),
79
80
  }, options.markdown);
80
- return { artifact: memoryArtifact(createMemoryCell(squareState)), clock: options.clock ?? Date.now, location: 'memory', hostLedger: options.hostLedger };
81
+ return { artifact: memoryArtifact(createMemoryCell(squareState)), clock: options.clock ?? Date.now, location: 'memory', hostLedger: options.hostLedger, wakeTransport: options.wakeTransport };
81
82
  }
82
83
  function memoryArtifact(cell) {
83
84
  return { read: () => cell.read(), transact: (fn) => cell.transact(fn), changed: (since, timeout) => cell.changed(since, timeout), close: () => cell.close() };
@@ -2,6 +2,7 @@ import { formatActivityId, parseActivityId } from './square-core.js';
2
2
  import { nameKey } from './model.js';
3
3
  import { deriveDeliveryModel } from './delivery.js';
4
4
  import { freshWatchLease } from './runtime.js';
5
+ import { canonicalRouteLocation } from './routes.js';
5
6
  function bindingProjection(record) {
6
7
  return {
7
8
  location: record.location,
@@ -53,14 +54,18 @@ export function terminalWakeEvidence(attempts) { return attempts.findLast((attem
53
54
  export function isWakeRouteAttemptable(route, attempts) {
54
55
  if (terminalWakeEvidence(attempts) !== undefined)
55
56
  return false;
56
- const failed = attempts.findLast((attempt) => attempt.routeKind === route.kind && attempt.outcome === 'failed');
57
- return failed === undefined || route.updatedAt > failed.at;
57
+ if (attempts.some((attempt) => attempt.routeKind === route.kind && attempt.outcome === 'unknown'))
58
+ return false;
59
+ return true;
58
60
  }
59
61
  export function hasAttemptableWakeRoute(routes, attempts) { return routes.some((route) => isWakeRouteAttemptable(route, attempts)); }
60
62
  export async function projectWakeEvidenceFromState(input) {
63
+ const canonicalLocation = await canonicalRouteLocation(input.location);
61
64
  const delivery = input.delivery ?? deriveDeliveryModel(input.state);
62
- const bindings = (await input.hostLedger.listPresence({ location: input.location, scopes: ['user'], now: input.now })).map(bindingProjection);
63
- const wakeRecords = await input.hostLedger.listEvidence({ location: input.location, kind: 'wake', now: input.now });
65
+ const bindings = (input.state.routes ?? [])
66
+ .filter((route) => route.location === canonicalLocation || route.location === input.location)
67
+ .map((route) => ({ location: route.location, participant: route.participant, sessionId: route.sessionId, channel: route.channel, route: { ...route, address: { ...route.address } }, updatedAt: route.updatedAt }));
68
+ const wakeRecords = await input.hostLedger.listEvidence({ location: canonicalLocation, kind: 'wake', now: input.now });
64
69
  const attemptsByBinding = new Map();
65
70
  for (const record of wakeRecords) {
66
71
  const actIndex = parseActivityId(record.activity);
@@ -72,7 +77,7 @@ export async function projectWakeEvidenceFromState(input) {
72
77
  existing.push(attempt);
73
78
  attemptsByBinding.set(key, existing);
74
79
  }
75
- const presentedRows = await projectPresentationEvidence({ hostLedger: input.hostLedger, location: input.location, now: input.now });
80
+ const presentedRows = await projectPresentationEvidence({ hostLedger: input.hostLedger, location: canonicalLocation, now: input.now });
76
81
  return {
77
82
  evidence(recipient, actIndex) {
78
83
  const recipientBindings = bindings.filter((binding) => nameKey(binding.participant) === nameKey(recipient));
package/dist/wake-port.js CHANGED
@@ -14,7 +14,7 @@ export class WakePort {
14
14
  if (result.outcome === 'cancelled')
15
15
  return result;
16
16
  if (result.outcome === 'unavailable') {
17
- if (result.retainRoute !== true)
17
+ if (result.routeStale === true)
18
18
  await hooks.invalidate?.(route, result);
19
19
  continue;
20
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.31",
3
+ "version": "0.3.32",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {