@astrosheep/square 0.3.31 → 0.3.33

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.33",
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.33",
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 { PRIVILEGED_HOOK_BUDGET_MS, sweepPrivilegedPending } from './notifications.js';
4
5
  export async function runClaudeHookAsync(inputText, env = process.env) {
5
6
  let input;
6
7
  try {
@@ -34,12 +35,15 @@ 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) {
39
+ const sweepDeadline = Date.now() + PRIVILEGED_HOOK_BUDGET_MS;
38
40
  if (typeof input.session_id !== 'string' || input.session_id === '')
39
41
  return undefined;
40
42
  if (input.hook_event_name !== 'PostToolBatch')
41
43
  return undefined;
42
- return presentPendingAtBoundary(input.session_id, (context) => ({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: context } }), lookup, env);
44
+ const response = await presentPendingAtBoundary(input.session_id, (context) => ({ hookSpecificOutput: { hookEventName: 'PostToolBatch', additionalContext: context } }), lookup, env);
45
+ await sweepPrivilegedPending(typeof input.cwd === 'string' ? input.cwd : process.cwd(), env, deliveryAdapters, sweepDeadline).catch(() => undefined);
46
+ return response;
43
47
  }
44
48
  export async function runClaudeHook(inputText, env = process.env) {
45
49
  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,13 @@ 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 { PRIVILEGED_HOOK_BUDGET_MS, 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) {
11
+ const sweepDeadline = Date.now() + PRIVILEGED_HOOK_BUDGET_MS;
10
12
  if (typeof input.session_id !== 'string' || input.session_id === '')
11
13
  return undefined;
12
14
  if (typeof input.hook_event_name !== 'string')
@@ -15,9 +17,11 @@ export async function codexHookResponse(input, lookup = sessionInbox, env = proc
15
17
  if (hookEventName === undefined)
16
18
  return undefined;
17
19
  await recordCodexBoundary(input.session_id, hookEventName === 'Stop' ? 'Stop' : 'non-stop', env);
18
- return presentPendingAtBoundary(input.session_id, (context) => hookEventName === 'Stop'
20
+ const response = await presentPendingAtBoundary(input.session_id, (context) => hookEventName === 'Stop'
19
21
  ? { systemMessage: context }
20
22
  : { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } }, lookup, env);
23
+ await sweepPrivilegedPending(typeof input.cwd === 'string' ? input.cwd : process.cwd(), env, deliveryAdapters, sweepDeadline).catch(() => undefined);
24
+ return response;
21
25
  }
22
26
  export async function runCodexHook(inputText, env = process.env) {
23
27
  let input;
@@ -22,6 +22,6 @@ export declare class CodexQueueAdapter implements WakeAdapter {
22
22
  private readonly opts;
23
23
  readonly kind: "codex-queue";
24
24
  constructor(opts?: CodexQueueAdapterOptions);
25
- dispatch(address: Readonly<Record<string, string>>, payload: string, beforeSend: () => Promise<boolean>): Promise<WakeDispatchResult>;
25
+ dispatch(address: Readonly<Record<string, string>>, payload: string, beforeSend: () => Promise<boolean>, timeoutMs?: number): Promise<WakeDispatchResult>;
26
26
  }
27
27
  export type { WakeRoute };
@@ -33,10 +33,12 @@ export class CodexQueueAdapter {
33
33
  constructor(opts = {}) {
34
34
  this.opts = opts;
35
35
  }
36
- async dispatch(address, payload, beforeSend) {
36
+ async dispatch(address, payload, beforeSend, timeoutMs = 5000) {
37
+ const deadline = Date.now() + timeoutMs;
38
+ const remainingMs = () => Math.max(0, deadline - Date.now());
37
39
  const threadId = address.threadId?.trim();
38
40
  if (!threadId) {
39
- return { outcome: 'unavailable', signature: 'invalid_address', message: 'Codex route has no thread id.' };
41
+ return { outcome: 'unavailable', signature: 'invalid_address', message: 'Codex route has no thread id.', routeStale: true };
40
42
  }
41
43
  const env = this.opts.env ?? process.env;
42
44
  if (!await codexQueueEligible(threadId, env)) {
@@ -57,8 +59,12 @@ export class CodexQueueAdapter {
57
59
  retainRoute: true,
58
60
  };
59
61
  }
62
+ const remaining = remainingMs();
63
+ if (remaining === 0) {
64
+ return { outcome: 'unavailable', signature: 'dispatch_budget_exhausted', message: 'The wake dispatch budget elapsed before queueing.', retainRoute: true };
65
+ }
60
66
  try {
61
- (this.opts.sendQueue ?? sendCodexQueue)({ threadId, message: payload }, { env });
67
+ (this.opts.sendQueue ?? sendCodexQueue)({ threadId, message: payload }, { env, timeoutMs: remaining });
62
68
  return { outcome: 'accepted' };
63
69
  }
64
70
  catch (error) {
@@ -1,11 +1,34 @@
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';
7
+ async function attemptWakeWithin(transport, request, timeoutMs) {
8
+ let timer;
9
+ const timeout = new Promise((resolve) => {
10
+ timer = setTimeout(() => resolve({ outcome: 'unknown', diagnostic: 'transport timeout' }), timeoutMs);
11
+ });
12
+ try {
13
+ return await Promise.race([transport.attempt(request, timeoutMs), timeout]);
14
+ }
15
+ finally {
16
+ if (timer !== undefined)
17
+ clearTimeout(timer);
18
+ }
19
+ }
5
20
  export async function observeSquare(input) {
6
21
  const snapshot = await input.artifact.read();
7
22
  const delivery = deriveDeliveryModel(snapshot.state);
8
- const rows = input.hostLedger === undefined ? [] : await input.hostLedger.listPresence({ location: input.location, scopes: ['user', 'local'], now: input.now });
23
+ let rows = [];
24
+ if (input.hostLedger !== undefined) {
25
+ try {
26
+ rows = await input.hostLedger.listPresence({ location: input.location, scopes: ['user', 'local'], now: input.now });
27
+ }
28
+ catch {
29
+ rows = [];
30
+ }
31
+ }
9
32
  const bindings = rows.map((record) => ({
10
33
  location: record.location,
11
34
  participant: record.participant,
@@ -21,40 +44,94 @@ export async function reconcileBinding(input) {
21
44
  }
22
45
  export async function deliverPending(input) {
23
46
  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);
47
+ const routes = (observation.state.routes ?? []).map((route) => ({
48
+ location: route.location,
49
+ participant: route.participant,
50
+ session: route.sessionId,
51
+ channel: route.channel,
52
+ route: { kind: route.kind, address: route.address },
53
+ updatedAt: route.updatedAt,
54
+ }));
25
55
  let attempted = 0;
26
56
  let accepted = 0;
27
57
  let failed = 0;
28
58
  let unknown = 0;
59
+ let notCapable = 0;
29
60
  for (const membership of observation.pending) {
30
61
  for (const notification of membership.notifications) {
31
- const candidates = routes.filter((route) => route.participant.toLocaleLowerCase() === membership.recipient.toLocaleLowerCase());
62
+ const candidates = routes.filter((route) => nameKey(route.participant) === nameKey(membership.recipient));
63
+ if (candidates.length === 0) {
64
+ notCapable += 1;
65
+ continue;
66
+ }
32
67
  if (input.activity !== undefined) {
33
68
  const requested = typeof input.activity === 'number' ? input.activity : parseActivityId(input.activity);
34
69
  if (requested === undefined || requested !== notification.item.index)
35
70
  continue;
36
71
  }
72
+ let acceptedForAttention = false;
73
+ try {
74
+ const prior = await input.hostLedger.listWakeAttempts({ attention: { squarePath: input.location, actIndex: notification.item.index, recipient: membership.recipient }, now: input.now });
75
+ acceptedForAttention = prior.some((attempt) => attempt.outcome === 'accepted');
76
+ }
77
+ catch { /* capability is handled by the route-level probe */ }
78
+ if (acceptedForAttention)
79
+ continue;
37
80
  for (const route of candidates) {
38
81
  const activity = formatActivityId(notification.item.index);
39
82
  const attention = { squarePath: input.location, actIndex: notification.item.index, recipient: membership.recipient };
40
83
  const leaseMs = input.timeoutMs ?? 5000;
84
+ 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 };
85
+ if (input.transport.probe !== undefined) {
86
+ try {
87
+ const probe = await input.transport.probe(requestRoute);
88
+ if (probe === false || (typeof probe === 'object' && probe.outcome === 'not-capable')) {
89
+ notCapable += 1;
90
+ continue;
91
+ }
92
+ }
93
+ catch {
94
+ notCapable += 1;
95
+ continue;
96
+ }
97
+ }
41
98
  let leaseId = randomUUID();
42
- let lease = await input.hostLedger.claimWakeDispatch({ attention, leaseId, leaseMs, session: route.session, at: input.now });
99
+ let lease;
100
+ try {
101
+ lease = await input.hostLedger.claimWakeDispatch({ attention, leaseId, leaseMs, session: route.session, at: input.now });
102
+ }
103
+ catch {
104
+ notCapable += 1;
105
+ continue;
106
+ }
43
107
  if (lease.type === 'ambiguous') {
44
108
  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
109
  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 });
110
+ continue;
48
111
  }
49
112
  if (lease.type !== 'acquired')
50
113
  continue;
51
- const attempts = await input.hostLedger.listWakeAttempts({ attention, session: route.session, now: input.now });
114
+ let attempts;
115
+ try {
116
+ attempts = await input.hostLedger.listWakeAttempts({ attention, session: route.session, now: input.now });
117
+ }
118
+ catch {
119
+ notCapable += 1;
120
+ await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now }).catch(() => undefined);
121
+ continue;
122
+ }
123
+ if (attempts.some((attempt) => attempt.routeKind === route.route.kind && attempt.outcome === 'unknown')) {
124
+ await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now });
125
+ continue;
126
+ }
52
127
  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 } };
128
+ const request = { location: input.location, participant: membership.recipient, activity, route: requestRoute };
54
129
  let outcome;
55
130
  const claim = await input.hostLedger.claimEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', leaseMs, now: input.now });
56
131
  if (claim.status !== 'acquired') {
57
132
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now });
133
+ if (claim.status === 'degraded')
134
+ notCapable += 1;
58
135
  continue;
59
136
  }
60
137
  attempted += 1;
@@ -64,13 +141,20 @@ export async function deliverPending(input) {
64
141
  continue;
65
142
  }
66
143
  try {
67
- outcome = await Promise.race([input.transport.attempt(request, leaseMs), new Promise((resolve) => setTimeout(() => resolve({ outcome: 'unknown', diagnostic: 'transport timeout' }), leaseMs))]);
144
+ outcome = await attemptWakeWithin(input.transport, request, leaseMs);
68
145
  }
69
146
  catch (error) {
70
147
  outcome = { outcome: 'unknown', diagnostic: error instanceof Error ? error.message : String(error) };
71
148
  }
149
+ if (outcome.outcome === 'not-capable') {
150
+ await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', now: input.now }).catch(() => undefined);
151
+ await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now }).catch(() => undefined);
152
+ notCapable += 1;
153
+ continue;
154
+ }
72
155
  if (outcome.outcome === 'failed' && outcome.unavailable) {
73
- await input.transport.invalidate?.(request);
156
+ if (outcome.routeStale === true)
157
+ await retireWakeRouteFromArtifact(input.artifact, { location: route.location, participant: route.participant, sessionId: route.session });
74
158
  await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', now: input.now });
75
159
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now });
76
160
  failed += 1;
@@ -78,16 +162,19 @@ export async function deliverPending(input) {
78
162
  }
79
163
  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
164
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session, at: input.now });
81
- if (outcome.outcome === 'accepted')
165
+ if (outcome.outcome === 'accepted') {
82
166
  accepted += 1;
83
- else if (outcome.outcome === 'failed')
167
+ acceptedForAttention = true;
168
+ break;
169
+ }
170
+ if (outcome.outcome === 'failed')
84
171
  failed += 1;
85
172
  else
86
173
  unknown += 1;
87
174
  }
88
175
  }
89
176
  }
90
- return { attempted, accepted, failed, unknown };
177
+ return { attempted, accepted, failed, unknown, notCapable };
91
178
  }
92
179
  export function selectPendingWakeActivities(state, routes, attempts, now, graceMs, limit, delivery = deriveDeliveryModel(state)) {
93
180
  const selected = new Set();
@@ -95,10 +182,12 @@ export function selectPendingWakeActivities(state, routes, attempts, now, graceM
95
182
  for (const notification of delivery.pendingFor(membership)) {
96
183
  if (now - notification.item.at <= graceMs)
97
184
  continue;
185
+ if (attempts.some((attempt) => attempt.attention.actIndex === notification.item.index && nameKey(attempt.attention.recipient) === nameKey(membership) && attempt.outcome === 'accepted'))
186
+ continue;
98
187
  const eligible = routes.some((binding) => {
99
- if (binding.route === undefined || binding.participant.toLocaleLowerCase() !== membership.toLocaleLowerCase())
188
+ if (binding.route === undefined || nameKey(binding.participant) !== nameKey(membership))
100
189
  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);
190
+ const matching = attempts.filter((attempt) => attempt.session === binding.session && nameKey(attempt.attention.recipient) === nameKey(membership) && attempt.attention.actIndex === notification.item.index);
102
191
  return isWakeRouteAttemptable({ kind: binding.route.kind, updatedAt: binding.updatedAt ?? 0 }, matching);
103
192
  });
104
193
  if (eligible)
@@ -112,7 +201,14 @@ export async function sweepPending(input) {
112
201
  return sweepPendingFromState({ ...input, state });
113
202
  }
114
203
  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 })]);
204
+ 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 }));
205
+ let records = [];
206
+ try {
207
+ records = await input.hostLedger.listWakeAttempts({ now: input.now });
208
+ }
209
+ catch {
210
+ records = [];
211
+ }
116
212
  const attempts = records.flatMap((record) => {
117
213
  const index = parseActivityId(record.activity);
118
214
  if (index === undefined || record.routeKind === undefined || typeof record.attemptN !== 'number')
@@ -35,12 +35,13 @@ 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
  };
41
42
  export interface WakeAdapter {
42
43
  readonly kind: WakeRouteKind;
43
- dispatch(address: Readonly<Record<string, string>>, payload: string, beforeSend: () => Promise<boolean>): Promise<WakeDispatchResult>;
44
+ dispatch(address: Readonly<Record<string, string>>, payload: string, beforeSend: () => Promise<boolean>, timeoutMs?: number): Promise<WakeDispatchResult>;
44
45
  }
45
46
  export interface RoutedNotification {
46
47
  actor: string;
@@ -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,9 +2,11 @@ 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';
9
+ export declare const PRIVILEGED_HOOK_BUDGET_MS = 3000;
8
10
  export declare function wakeGraceMs(env?: NodeJS.ProcessEnv): number;
9
11
  export declare function hasDeliveredNotification(squarePath: string, name: string, ref: number | ActivityId): Promise<boolean>;
10
12
  export declare function hasAttentionNotification(squarePath: string, name: string, ref: number | ActivityId, env?: NodeJS.ProcessEnv): Promise<boolean>;
@@ -16,7 +18,12 @@ interface ProcessNotificationOptions {
16
18
  env?: NodeJS.ProcessEnv;
17
19
  now?: () => number;
18
20
  }
21
+ export declare function defaultWakeAdapters(): Promise<WakeAdapter[]>;
22
+ export declare function createDefaultWakeTransport(hostLedger: import('./host-ledger.js').HostLedgerPort, clock: () => number, env?: NodeJS.ProcessEnv): Promise<WakeTransportPort>;
23
+ export declare function createWakeTransport(adapters: readonly WakeAdapter[], hostLedger: import('./host-ledger.js').HostLedgerPort, clock: () => number): WakeTransportPort;
19
24
  export declare function processActNotificationsOnce(squarePath: string, actIndex: number, opts?: ProcessNotificationOptions): Promise<import("./ports.js").DeliveryResult>;
25
+ /** Privileged hook fallback: sweep indexed squares plus the current cwd's local squares within one boundary budget. */
26
+ export declare function sweepPrivilegedPending(cwd: string, env?: NodeJS.ProcessEnv, suppliedAdapters?: WakeAdapter[], deadline?: number): Promise<void>;
20
27
  export interface SweepPendingNotificationsOptions {
21
28
  env?: NodeJS.ProcessEnv;
22
29
  now?: number;