@astrosheep/square 0.3.56 → 0.3.58

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.56",
3
+ "version": "0.3.58",
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.56",
3
+ "version": "0.3.58",
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
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { setTimeout as sleep } from 'node:timers/promises';
5
5
  import { SquareError, isSquareError, validateName } from './model.js';
6
- import { expressHintLine, renderActivityBlocked, renderActivityLimit, renderExpressNoWait, renderExpressWaiting, renderPendingFeed, withPathOutput, } from './presentation.js';
6
+ import { expressHintLine, renderActivityBlocked, renderActivityLimit, renderExpressNoWait, renderExpressWaiting, renderPendingFeed, joinRecoveryCommand, withPathOutput, } from './presentation.js';
7
7
  import { nowMs, SLEEP_MS } from './runtime.js';
8
8
  import { openSquare } from './square-file-adapter.js';
9
9
  import { closeOpenSquare } from './open-square.js';
@@ -55,6 +55,8 @@ export async function cmdActivity(squarePath, name, activity, resolveBody, opts)
55
55
  const draftPath = saveActivityDraft(squarePath, name, rawInput);
56
56
  process.stderr.write(err.message + '\n');
57
57
  process.stderr.write(`draft kept: ${draftPath}\n`);
58
+ if (/^Unknown participant/.test(err.message))
59
+ process.stderr.write(`${joinRecoveryCommand(squarePath, name)}\n`);
58
60
  process.exit(2);
59
61
  }
60
62
  process.stderr.write(err.message + '\n');
@@ -1,13 +1,20 @@
1
1
  import { helpRequest } from '../help.js';
2
2
  import { isSquareError } from '../model.js';
3
- import { commandPrefix } from '../presentation.js';
3
+ import { joinRecoveryCommand, participantsRecoveryCommand } from '../presentation.js';
4
4
  import { defaultContext, parseGlobalArgs } from './context.js';
5
5
  import { executeRegisteredCommand, findCommand } from './registry.js';
6
- function handleSquareError(error, squarePath) {
6
+ function handleSquareError(error, squarePath, name) {
7
7
  if (isSquareError(error)) {
8
8
  process.stderr.write(`${error.message}\n`);
9
- if (squarePath !== undefined && /^(Unknown participant|Unknown mention target)/.test(error.message)) {
10
- process.stderr.write(`» ${commandPrefix(squarePath)} participants\n`);
9
+ if (squarePath !== undefined && /^Unknown participant/.test(error.message)) {
10
+ // The caller's name has never joined this square; only a join admits it.
11
+ if (name !== undefined)
12
+ process.stderr.write(`${joinRecoveryCommand(squarePath, name)}\n`);
13
+ else
14
+ process.stderr.write(`${participantsRecoveryCommand(squarePath)}\n`);
15
+ }
16
+ else if (squarePath !== undefined && /^Unknown mention target/.test(error.message)) {
17
+ process.stderr.write(`${participantsRecoveryCommand(squarePath)}\n`);
11
18
  }
12
19
  process.exit(error.code === 'not_found' ? 1 : 2);
13
20
  }
@@ -16,6 +23,7 @@ function handleSquareError(error, squarePath) {
16
23
  /** Parse global flags, select an executable adapter, and leave all command work to the registry. */
17
24
  export async function runCli(rawArgs = process.argv.slice(2)) {
18
25
  let squarePath;
26
+ let requestedName;
19
27
  try {
20
28
  const requestedHelp = helpRequest(rawArgs);
21
29
  if (requestedHelp !== undefined) {
@@ -24,6 +32,7 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
24
32
  }
25
33
  const parsed = await parseGlobalArgs(rawArgs);
26
34
  squarePath = parsed.squarePath;
35
+ requestedName = parsed.name;
27
36
  if (parsed.args.length === 0 || parsed.args[0] === '--help' || parsed.args[0] === '-h') {
28
37
  await executeRegisteredCommand('help', [], defaultContext('help', parsed.squarePath, parsed.name));
29
38
  return;
@@ -36,6 +45,6 @@ export async function runCli(rawArgs = process.argv.slice(2)) {
36
45
  await executeRegisteredCommand(command, parsed.args.slice(1), defaultContext(command, parsed.squarePath, parsed.name));
37
46
  }
38
47
  catch (error) {
39
- handleSquareError(error, squarePath);
48
+ handleSquareError(error, squarePath, requestedName);
40
49
  }
41
50
  }
@@ -1,5 +1,6 @@
1
1
  import { type DirectedNotificationRoute } from './delivery.js';
2
- import type { WakeAttempt } from './square-projections.js';
2
+ import { type WakeAttempt } from './square-projections.js';
3
+ import { type WakeReleaseDiagnostic } from './wake-attempts.js';
3
4
  export type DeliveryHealthKind = 'awaiting' | 'wake-accepted' | 'wake-unknown' | 'presented-not-delivered' | 'unreachable';
4
5
  export interface DeliveryHealthItem {
5
6
  squarePath: string;
@@ -18,5 +19,5 @@ export declare function classifyDeliveryHealth(squarePath: string, opts: {
18
19
  now?: number;
19
20
  env?: NodeJS.ProcessEnv;
20
21
  }): Promise<DeliveryHealthItem[]>;
21
- export declare function renderDeliveryHealth(items: readonly DeliveryHealthItem[]): string[];
22
+ export declare function renderDeliveryHealth(items: readonly DeliveryHealthItem[], releases?: readonly WakeReleaseDiagnostic[], now?: number): string[];
22
23
  export declare function doctorDeliveryHealth(squarePath: string, graceMs: number, now?: number, env?: NodeJS.ProcessEnv): Promise<string[]>;
@@ -1,10 +1,12 @@
1
+ import { deriveDeliveryModel } from './delivery.js';
1
2
  import { formatActivityId } from './square-core.js';
2
3
  import { participantIdentity, truncateChars } from './presentation.js';
3
4
  import { formatDuration } from './time.js';
4
- import { wakeEvidence } from './wake-evidence.js';
5
+ import { projectWakeEvidenceFromState } from './square-projections.js';
5
6
  import { openSquare } from './square-file-adapter.js';
6
7
  import { closeOpenSquare } from './open-square.js';
7
- import { pendingDeliveries } from './views.js';
8
+ import { pendingDeliveriesFromState } from './views.js';
9
+ import { readWakeReleaseDiagnostics } from './wake-attempts.js';
8
10
  const DISPLAY_ORDER = [
9
11
  'awaiting',
10
12
  'wake-accepted',
@@ -19,37 +21,51 @@ const DISPLAY_FIELD_LIMIT = 160;
19
21
  export async function classifyDeliveryHealth(squarePath, opts) {
20
22
  const now = opts.now ?? Date.now();
21
23
  const env = opts.env ?? process.env;
22
- const square = await openSquare(squarePath, { clock: () => now });
23
- const pending = await pendingDeliveries(square).finally(() => closeOpenSquare(square));
24
- const items = [];
25
- for (const delivery of pending) {
26
- for (const note of delivery.notifications) {
27
- const ageMs = Math.max(0, now - note.item.at);
28
- const evidence = await wakeEvidence(squarePath, note.recipient, note.item.index, now, env);
29
- const kind = evidence.presented
30
- ? 'presented-not-delivered'
31
- : evidence.terminal?.outcome === 'accepted'
32
- ? 'wake-accepted'
33
- : evidence.terminal?.outcome === 'unknown'
34
- ? 'wake-unknown'
35
- : ageMs > opts.graceMs && evidence.attemptableRoutes.length === 0
36
- ? 'unreachable'
37
- : 'awaiting';
38
- const attempt = evidence.terminal ?? evidence.attempts.at(-1);
39
- items.push({
40
- squarePath,
41
- recipient: note.recipient,
42
- actIndex: note.item.index,
43
- actor: note.item.actor,
44
- at: note.item.at,
45
- ageMs,
46
- route: note.route,
47
- kind,
48
- ...(attempt === undefined ? {} : { attempt }),
49
- });
24
+ const square = await openSquare(squarePath, { clock: () => now, env });
25
+ try {
26
+ const { state } = await square.artifact.read();
27
+ const delivery = deriveDeliveryModel(state);
28
+ const pending = pendingDeliveriesFromState(state, delivery);
29
+ const evidenceProjection = await projectWakeEvidenceFromState({
30
+ location: squarePath,
31
+ state,
32
+ hostLedger: square.hostLedger,
33
+ now,
34
+ delivery,
35
+ });
36
+ const items = [];
37
+ for (const pendingDelivery of pending) {
38
+ for (const note of pendingDelivery.notifications) {
39
+ const ageMs = Math.max(0, now - note.item.at);
40
+ const evidence = evidenceProjection.evidence(pendingDelivery.recipient, note.item.index);
41
+ const kind = evidence.presented
42
+ ? 'presented-not-delivered'
43
+ : evidence.terminal?.outcome === 'accepted'
44
+ ? 'wake-accepted'
45
+ : evidence.terminal?.outcome === 'unknown'
46
+ ? 'wake-unknown'
47
+ : ageMs > opts.graceMs && evidence.attemptableRoutes.length === 0
48
+ ? 'unreachable'
49
+ : 'awaiting';
50
+ const attempt = evidence.terminal ?? evidence.attempts.at(-1);
51
+ items.push({
52
+ squarePath,
53
+ recipient: pendingDelivery.recipient,
54
+ actIndex: note.item.index,
55
+ actor: note.item.actor,
56
+ at: note.item.at,
57
+ ageMs,
58
+ route: note.route,
59
+ kind,
60
+ ...(attempt === undefined ? {} : { attempt }),
61
+ });
62
+ }
50
63
  }
64
+ return items;
65
+ }
66
+ finally {
67
+ await closeOpenSquare(square);
51
68
  }
52
- return items;
53
69
  }
54
70
  function displayField(value) {
55
71
  const truncated = truncateChars(value, DISPLAY_FIELD_LIMIT - 1);
@@ -59,24 +75,40 @@ function formatItem(item) {
59
75
  const evidence = item.attempt?.signature === undefined ? '' : ` · ${displayField(item.attempt.signature)}`;
60
76
  return ` · ${formatActivityId(item.actIndex)} → ${displayField(participantIdentity(item.recipient))} from ${displayField(participantIdentity(item.actor))} · ${formatDuration(item.ageMs)}${evidence}`;
61
77
  }
62
- export function renderDeliveryHealth(items) {
63
- if (items.length === 0)
64
- return ['✓ no pending delivery attention'];
65
- const out = [`· delivery attention · ${items.length} pending`];
66
- let displayed = 0;
67
- for (const kind of DISPLAY_ORDER) {
68
- const group = items.filter((item) => item.kind === kind);
69
- if (group.length === 0)
70
- continue;
71
- out.push(`${ACTIONABLE.has(kind) ? '✕' : '○'} ${kind}: ${group.length}`);
72
- const details = group.slice(0, DETAIL_LIMIT - displayed);
73
- out.push(...details.map(formatItem));
74
- displayed += details.length;
78
+ export function renderDeliveryHealth(items, releases = [], now = Date.now()) {
79
+ const out = items.length === 0 ? ['✓ no pending delivery attention'] : [`· delivery attention · ${items.length} pending`];
80
+ if (items.length > 0) {
81
+ let displayed = 0;
82
+ for (const kind of DISPLAY_ORDER) {
83
+ const group = items.filter((item) => item.kind === kind);
84
+ if (group.length === 0)
85
+ continue;
86
+ out.push(`${ACTIONABLE.has(kind) ? '✕' : '○'} ${kind}: ${group.length}`);
87
+ const details = group.slice(0, DETAIL_LIMIT - displayed);
88
+ out.push(...details.map(formatItem));
89
+ displayed += details.length;
90
+ }
91
+ if (items.length > DETAIL_LIMIT)
92
+ out.push(`${DETAIL_LIMIT} of ${items.length} pending details shown`);
93
+ }
94
+ if (releases.length > 0) {
95
+ out.push(`· recent wake releases · ${releases.length}`);
96
+ out.push(...releases.slice(0, DETAIL_LIMIT).map((release) => formatRelease(release, now)));
97
+ if (releases.length > DETAIL_LIMIT)
98
+ out.push(`${DETAIL_LIMIT} of ${releases.length} wake releases shown`);
75
99
  }
76
- if (items.length > DETAIL_LIMIT)
77
- out.push(`${DETAIL_LIMIT} of ${items.length} pending details shown`);
78
100
  return out;
79
101
  }
102
+ function formatRelease(release, now) {
103
+ const route = displayField(release.routeKind ?? 'unknown route');
104
+ const reason = displayField(release.signature ?? 'reason not recorded');
105
+ const age = formatDuration(Math.max(0, now - release.at));
106
+ const message = release.message === undefined ? '' : ` — ${displayField(release.message)}`;
107
+ const diagnostic = release.diagnostic === undefined ? '' : ` · ${displayField(JSON.stringify(release.diagnostic))}`;
108
+ return ` · ${formatActivityId(release.attention.actIndex)} → ${displayField(participantIdentity(release.attention.recipient))} · ${route} · ${reason} · ${age}${message}${diagnostic}`;
109
+ }
80
110
  export async function doctorDeliveryHealth(squarePath, graceMs, now = Date.now(), env = process.env) {
81
- return renderDeliveryHealth(await classifyDeliveryHealth(squarePath, { graceMs, now, env }));
111
+ const items = await classifyDeliveryHealth(squarePath, { graceMs, now, env });
112
+ const releases = await readWakeReleaseDiagnostics({ location: squarePath, now, env });
113
+ return renderDeliveryHealth(items, releases, now);
82
114
  }
@@ -4,6 +4,22 @@ import { nameKey } from './model.js';
4
4
  import { deriveDeliveryModel } from './delivery.js';
5
5
  import { currentSessionBindings, isWakeRouteAttemptable, presentationSuppressesWake, projectPresentationEvidence } from './square-projections.js';
6
6
  import { retireWakeRouteFromArtifact } from './routes.js';
7
+ import { redactCurrentDiagnostic } from './diagnostic-redaction.js';
8
+ async function releaseWakeClaim(input, details) {
9
+ await input.hostLedger.releaseEvidence({
10
+ location: input.location,
11
+ participant: input.participant,
12
+ session: input.session,
13
+ activity: input.activity,
14
+ kind: 'wake',
15
+ claimToken: input.claimToken,
16
+ ...(input.routeKind === undefined ? {} : { routeKind: input.routeKind }),
17
+ ...(input.attemptN === undefined ? {} : { attemptN: input.attemptN }),
18
+ signature: details.signature,
19
+ message: redactCurrentDiagnostic(details.message),
20
+ ...(details.diagnostic === undefined ? {} : { diagnostic: redactCurrentDiagnostic(details.diagnostic) }),
21
+ });
22
+ }
7
23
  async function attemptWakeWithin(transport, request, timeoutMs, beforeSend) {
8
24
  let timer;
9
25
  const timeout = new Promise((resolve) => {
@@ -119,7 +135,10 @@ export async function deliverPending(input) {
119
135
  if (lease.type === 'ambiguous') {
120
136
  let recovered = await input.hostLedger.claimEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', leaseMs, claimToken: lease.lease.leaseId });
121
137
  if (recovered.status === 'busy' && recovered.record.claimToken !== undefined) {
122
- await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', claimToken: recovered.record.claimToken });
138
+ await releaseWakeClaim({ hostLedger: input.hostLedger, location: input.location, participant: membership.recipient, session: route.session, activity, claimToken: recovered.record.claimToken, routeKind: lease.lease.routeKind ?? route.route.kind, attemptN: lease.lease.attemptN }, {
139
+ signature: 'stale_dispatch_claim_recovered',
140
+ message: 'A stale dispatch claim was released so recovery could continue.',
141
+ });
123
142
  recovered = await input.hostLedger.claimEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', leaseMs, claimToken: lease.lease.leaseId });
124
143
  }
125
144
  if (recovered.status === 'acquired') {
@@ -168,7 +187,10 @@ export async function deliverPending(input) {
168
187
  const claimToken = claim.claimToken;
169
188
  const dispatching = await input.hostLedger.transitionWakeDispatch({ attention, leaseId, phase: 'dispatching', leaseMs, routeKind: route.route.kind, attemptN, session: route.session });
170
189
  if (!dispatching) {
171
- await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', claimToken }).catch(() => undefined);
190
+ await releaseWakeClaim({ hostLedger: input.hostLedger, location: input.location, participant: membership.recipient, session: route.session, activity, claimToken, routeKind: route.route.kind, attemptN }, {
191
+ signature: 'dispatch_claim_transition_failed',
192
+ message: 'The wake dispatch claim could not enter the dispatching phase.',
193
+ }).catch(() => undefined);
172
194
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session });
173
195
  continue;
174
196
  }
@@ -190,7 +212,11 @@ export async function deliverPending(input) {
190
212
  && published.kind === route.route.kind
191
213
  && JSON.stringify(published.address) === JSON.stringify(route.route.address));
192
214
  if (!stillPending || !stillBound || !stillPublished) {
193
- await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', claimToken }).catch(() => undefined);
215
+ await releaseWakeClaim({ hostLedger: input.hostLedger, location: input.location, participant: membership.recipient, session: route.session, activity, claimToken, routeKind: route.route.kind, attemptN }, {
216
+ signature: 'pre_send_revalidation_failed',
217
+ message: 'Wake was not sent because the activity, session binding, or published route changed before dispatch.',
218
+ diagnostic: { pending: stillPending, bound: stillBound, publishedRoute: stillPublished },
219
+ }).catch(() => undefined);
194
220
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session }).catch(() => undefined);
195
221
  continue;
196
222
  }
@@ -200,7 +226,11 @@ export async function deliverPending(input) {
200
226
  }
201
227
  catch { /* capability is handled by the transport path */ }
202
228
  if (presentationSuppressesWake(latestPresentations)) {
203
- await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', claimToken }).catch(() => undefined);
229
+ await releaseWakeClaim({ hostLedger: input.hostLedger, location: input.location, participant: membership.recipient, session: route.session, activity, claimToken, routeKind: route.route.kind, attemptN }, {
230
+ signature: 'presentation_already_recorded',
231
+ message: 'Wake was suppressed because presentation evidence already exists.',
232
+ diagnostic: { outcomes: latestPresentations.map((row) => row.outcome) },
233
+ }).catch(() => undefined);
204
234
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session }).catch(() => undefined);
205
235
  continue;
206
236
  }
@@ -221,13 +251,20 @@ export async function deliverPending(input) {
221
251
  outcome = { outcome: 'unknown', diagnostic: error instanceof Error ? error.message : String(error) };
222
252
  }
223
253
  if (suppressedDuringSend) {
224
- await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', claimToken }).catch(() => undefined);
254
+ await releaseWakeClaim({ hostLedger: input.hostLedger, location: input.location, participant: membership.recipient, session: route.session, activity, claimToken, routeKind: route.route.kind, attemptN }, {
255
+ signature: 'presentation_recorded_during_dispatch',
256
+ message: 'Wake was cancelled because presentation evidence appeared before the final send check.',
257
+ }).catch(() => undefined);
225
258
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session }).catch(() => undefined);
226
259
  continue;
227
260
  }
228
261
  attempted += 1;
229
262
  if (outcome.outcome === 'not-capable') {
230
- await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', claimToken }).catch(() => undefined);
263
+ await releaseWakeClaim({ hostLedger: input.hostLedger, location: input.location, participant: membership.recipient, session: route.session, activity, claimToken, routeKind: route.route.kind, attemptN }, {
264
+ signature: 'transport_not_capable',
265
+ message: 'The wake transport reported that it could not deliver this route.',
266
+ diagnostic: outcome.diagnostic,
267
+ }).catch(() => undefined);
231
268
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session }).catch(() => undefined);
232
269
  notCapableForAttention = true;
233
270
  continue;
@@ -235,12 +272,17 @@ export async function deliverPending(input) {
235
272
  if (outcome.outcome === 'failed' && outcome.unavailable) {
236
273
  if (outcome.routeStale === true)
237
274
  await retireWakeRouteFromArtifact(input.artifact, { location: route.location, participant: route.participant, sessionId: route.session });
238
- await input.hostLedger.releaseEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', claimToken });
275
+ await releaseWakeClaim({ hostLedger: input.hostLedger, location: input.location, participant: membership.recipient, session: route.session, activity, claimToken, routeKind: route.route.kind, attemptN: outcome.attemptN ?? attemptN }, {
276
+ signature: outcome.signature ?? 'transport_unavailable',
277
+ message: outcome.message ?? 'The wake transport was unavailable.',
278
+ diagnostic: outcome.diagnostic,
279
+ });
239
280
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session });
240
281
  failedForAttention = true;
241
282
  continue;
242
283
  }
243
- 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 }), claimToken });
284
+ const safeOutcome = redactCurrentDiagnostic(outcome);
285
+ await input.hostLedger.appendEvidence({ location: input.location, participant: membership.recipient, session: route.session, activity, kind: 'wake', outcome: safeOutcome.outcome, routeKind: route.route.kind, attemptN: safeOutcome.attemptN ?? attemptN, ...(safeOutcome.signature === undefined ? {} : { signature: safeOutcome.signature }), ...(safeOutcome.outcome !== 'accepted' && safeOutcome.message !== undefined ? { message: safeOutcome.message } : {}), ...(safeOutcome.outcome !== 'accepted' && safeOutcome.diagnostic !== undefined ? { diagnostic: safeOutcome.diagnostic } : {}), claimToken });
244
286
  await input.hostLedger.releaseWakeDispatch({ attention, leaseId, session: route.session });
245
287
  if (outcome.outcome === 'accepted') {
246
288
  acceptedForAttention = true;
@@ -0,0 +1,2 @@
1
+ export declare function redactDiagnostic(value: unknown, secret: string | undefined): unknown;
2
+ export declare function redactCurrentDiagnostic(value: unknown, env?: NodeJS.ProcessEnv): unknown;
@@ -0,0 +1,40 @@
1
+ const MAX_STRING_LENGTH = 1_000;
2
+ const MAX_COLLECTION_ITEMS = 32;
3
+ const MAX_NESTING = 8;
4
+ function redactText(value, secret) {
5
+ const withoutSecret = secret?.trim() ? value.split(secret.trim()).join('[redacted]') : value;
6
+ const withoutUrlPassword = withoutSecret.replace(/([?&]password=)[^&\s]+/gi, '$1[redacted]');
7
+ return [...withoutUrlPassword].length <= MAX_STRING_LENGTH
8
+ ? withoutUrlPassword
9
+ : `${[...withoutUrlPassword].slice(0, MAX_STRING_LENGTH - 1).join('')}…`;
10
+ }
11
+ function redactValue(value, secret, seen, depth) {
12
+ if (typeof value === 'string')
13
+ return redactText(value, secret);
14
+ if (depth >= MAX_NESTING)
15
+ return '[truncated]';
16
+ if (Array.isArray(value)) {
17
+ if (seen.has(value))
18
+ return '[circular]';
19
+ seen.add(value);
20
+ return value.slice(0, MAX_COLLECTION_ITEMS).map((item) => redactValue(item, secret, seen, depth + 1));
21
+ }
22
+ if (value !== null && typeof value === 'object') {
23
+ if (seen.has(value))
24
+ return '[circular]';
25
+ seen.add(value);
26
+ return Object.fromEntries(Object.entries(value).slice(0, MAX_COLLECTION_ITEMS).map(([key, item]) => [
27
+ key,
28
+ /password|secret|api[-_]?key|token/i.test(key) && typeof item !== 'boolean'
29
+ ? '[redacted]'
30
+ : redactValue(item, secret, seen, depth + 1),
31
+ ]));
32
+ }
33
+ return value;
34
+ }
35
+ export function redactDiagnostic(value, secret) {
36
+ return redactValue(value, secret, new WeakSet(), 0);
37
+ }
38
+ export function redactCurrentDiagnostic(value, env = process.env) {
39
+ return redactDiagnostic(value, env.PASEO_PASSWORD);
40
+ }
package/dist/help.js CHANGED
@@ -66,7 +66,7 @@ const COMMANDS = [
66
66
  {
67
67
  names: ['harness'], usage: 'harness doctor [claude|codex|opencode|pi|delivery]', usesSquare: true, group: 'maintenance', hiddenFromIndex: true,
68
68
  summary: 'Diagnose installed agent-host support.',
69
- details: ['Targets:', ' claude, codex, opencode, pi Diagnose one installed adapter.', ' delivery Diagnose delivery for the selected square.'],
69
+ details: ['Targets:', ' claude, codex, opencode, pi Diagnose one installed adapter.', ' delivery Diagnose pending delivery and recent wake-release reasons for the selected square.'],
70
70
  },
71
71
  {
72
72
  names: ['doctor'], usage: 'doctor', usesSquare: true, group: 'maintenance',
@@ -154,11 +154,11 @@ export class FileHostLedgerPort {
154
154
  return { status: 'degraded', error };
155
155
  } }
156
156
  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 () => { const all = await read(f, at, true), same = (x) => x.kind === i.kind && x.location === location && nameKey(x.participant) === nameKey(i.participant) && x.activity === i.activity && x.session === i.session, current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== i.claimToken)
157
- return; const tombstone = { ...current, outcome: 'released', at, expiresAt: undefined }; delete tombstone.expiresAt; await write(f, [...all.filter(x => x !== current), tombstone]); }); }
157
+ return; const tombstone = { ...current, outcome: 'released', at, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.signature === undefined ? {} : { signature: i.signature }), ...(i.message === undefined ? {} : { message: i.message }), ...(i.diagnostic === undefined ? {} : { diagnostic: i.diagnostic }), expiresAt: undefined }; delete tombstone.expiresAt; await write(f, [...all.filter(x => x !== current), tombstone]); }); }
158
158
  async appendEvidence(i) { const f = this.file('user', 'evidence'), r = { ...i, location: await canon(i.location), at: i.at ?? this.clock(), v: 1 }; const token = i.claimToken; if (typeof token !== 'string' || token.length === 0)
159
159
  return; await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, r.at, true); const same = (x) => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session; const current = all.findLast(x => same(x) && x.outcome === 'dispatching'); if (current === undefined || current.claimToken !== token)
160
160
  return; const terminalKey = (x) => same(x) && x.attemptN === r.attemptN && x.outcome !== 'dispatching' && x.outcome !== 'released'; await write(f, [...all.filter(x => x !== current && !terminalKey(x)), r]); }); }
161
- 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 => r.outcome !== 'released' && (!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)); }
161
+ 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 => (i.includeReleased === true || r.outcome !== 'released') && (!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)); }
162
162
  async gcEvidence(i) { const f = this.file('user', 'evidence'), pending = new Set(i.pendingWakeActivities ?? []); await withFileLock(f + '.lock', LOCK, async () => write(f, (await read(f, this.clock(), true)).filter(r => r.at >= i.before || r.kind === 'wake' && r.outcome === 'accepted' && pending.has(r.activity)))); }
163
163
  async reconcileBinding(i = {}) { let b = []; try {
164
164
  b = [...await this.listPresence({ scopes: i.scopes, now: i.now })];
@@ -65,9 +65,15 @@ export interface EvidenceClaim {
65
65
  readonly claimToken?: string;
66
66
  readonly now?: number;
67
67
  }
68
- export type EvidenceRelease = Omit<EvidenceClaim, 'leaseMs'> & {
68
+ /** Release metadata and includeReleased are diagnostic-only; default evidence reads stay behavior-safe. */
69
+ export interface EvidenceRelease extends Omit<EvidenceClaim, 'leaseMs'> {
69
70
  readonly claimToken: string;
70
- };
71
+ readonly routeKind?: WakeRouteKind;
72
+ readonly attemptN?: number;
73
+ readonly signature?: string;
74
+ readonly message?: string;
75
+ readonly diagnostic?: unknown;
76
+ }
71
77
  export interface EvidenceLookup {
72
78
  readonly location?: string;
73
79
  readonly participant?: string;
@@ -75,6 +81,7 @@ export interface EvidenceLookup {
75
81
  readonly activity?: string;
76
82
  readonly kind?: EvidenceRecord['kind'];
77
83
  readonly now?: number;
84
+ readonly includeReleased?: boolean;
78
85
  }
79
86
  export interface EvidenceGc {
80
87
  readonly before: number;
@@ -102,28 +102,28 @@ export async function createDefaultWakeTransport(hostLedger, clock, env = proces
102
102
  const adapters = await defaultWakeAdapters();
103
103
  return createWakeTransport(env.SQUARE_DISABLE_PASEO_WAKE === '1' ? adapters.filter((adapter) => adapter.kind !== 'paseo') : adapters, hostLedger, clock);
104
104
  }
105
- async function wakeRequestIsCurrent(request, hostLedger, now) {
105
+ async function wakeRequestCurrentness(request, hostLedger, now) {
106
106
  const activity = parseActivityId(request.activity);
107
107
  if (activity === undefined)
108
- return false;
108
+ return { current: false, activityPending: false, sessionBound: false, routePublished: false, observationAvailable: true };
109
109
  let square;
110
110
  try {
111
111
  square = await openSquare(request.location, { hostLedger });
112
112
  const current = await observeSquare({ artifact: square.artifact, hostLedger, location: request.location, now });
113
- const pending = current.pending.some((entry) => nameKey(entry.recipient) === nameKey(request.participant)
113
+ const activityPending = current.pending.some((entry) => nameKey(entry.recipient) === nameKey(request.participant)
114
114
  && entry.notifications.some((notification) => notification.item.index === activity));
115
- const bound = current.bindings.some((binding) => nameKey(binding.participant) === nameKey(request.route.participant)
115
+ const sessionBound = current.bindings.some((binding) => nameKey(binding.participant) === nameKey(request.route.participant)
116
116
  && binding.sessionId === request.route.sessionId
117
117
  && binding.location === request.route.location);
118
- const published = (current.state.routes ?? []).some((route) => route.location === request.route.location
118
+ const routePublished = (current.state.routes ?? []).some((route) => route.location === request.route.location
119
119
  && nameKey(route.participant) === nameKey(request.route.participant)
120
120
  && route.sessionId === request.route.sessionId
121
121
  && route.kind === request.route.kind
122
122
  && JSON.stringify(route.address) === JSON.stringify(request.route.address));
123
- return pending && bound && published;
123
+ return { current: activityPending && sessionBound && routePublished, activityPending, sessionBound, routePublished, observationAvailable: true };
124
124
  }
125
125
  catch {
126
- return false;
126
+ return { current: false, activityPending: false, sessionBound: false, routePublished: false, observationAvailable: false };
127
127
  }
128
128
  finally {
129
129
  if (square !== undefined)
@@ -151,15 +151,32 @@ export function createWakeTransport(adapters, hostLedger, clock) {
151
151
  if (adapter === undefined)
152
152
  return { outcome: 'not-capable', diagnostic: `no adapter for ${request.route.kind}` };
153
153
  try {
154
- const result = await adapter.dispatch(request.route.address, renderWakePayload(request), async () => (await (beforeSend ?? (async () => true))()) && await wakeRequestIsCurrent(request, hostLedger, clock()), timeoutMs);
154
+ let revalidation;
155
+ const result = await adapter.dispatch(request.route.address, renderWakePayload(request), async () => {
156
+ if (!(await (beforeSend ?? (async () => true))()))
157
+ return false;
158
+ revalidation = await wakeRequestCurrentness(request, hostLedger, clock());
159
+ return revalidation.current;
160
+ }, timeoutMs);
155
161
  if (result.outcome === 'accepted')
156
162
  return { outcome: 'accepted' };
157
163
  if (result.outcome === 'failed')
158
- return { outcome: 'failed', message: result.message };
164
+ return { outcome: 'failed', ...(result.signature === undefined ? {} : { signature: result.signature }), message: result.message, ...(result.diagnostic === undefined ? {} : { diagnostic: result.diagnostic }) };
159
165
  if (result.outcome === 'unavailable')
160
- return { outcome: 'failed', message: result.message, unavailable: true, ...(result.retainRoute === true ? { retainRoute: true } : {}), ...(result.routeStale === true ? { routeStale: true } : {}) };
166
+ return { outcome: 'failed', signature: result.signature, message: result.message, ...(result.diagnostic === undefined ? {} : { diagnostic: result.diagnostic }), unavailable: true, ...(result.retainRoute === true ? { retainRoute: true } : {}), ...(result.routeStale === true ? { routeStale: true } : {}) };
161
167
  if (result.outcome === 'unknown')
162
- return { outcome: 'unknown', diagnostic: result.message };
168
+ return { outcome: 'unknown', ...(result.signature === undefined ? {} : { signature: result.signature }), ...(result.message === undefined ? {} : { message: result.message }), ...(result.diagnostic === undefined ? {} : { diagnostic: result.diagnostic }) };
169
+ if (result.outcome === 'cancelled' && revalidation !== undefined && !revalidation.current) {
170
+ return {
171
+ outcome: 'failed',
172
+ signature: 'pre_send_revalidation_failed',
173
+ message: revalidation.observationAvailable
174
+ ? 'Wake was not sent because current attention, session binding, or route no longer matches.'
175
+ : 'Wake was not sent because current Square delivery state could not be verified.',
176
+ diagnostic: revalidation,
177
+ unavailable: true,
178
+ };
179
+ }
163
180
  return { outcome: 'unknown', diagnostic: 'wake dispatch cancelled' };
164
181
  }
165
182
  catch (error) {
package/dist/ports.d.ts CHANGED
@@ -69,6 +69,8 @@ export type WakeOutcome = {
69
69
  } | {
70
70
  readonly outcome: 'failed';
71
71
  readonly message?: string;
72
+ readonly signature?: string;
73
+ readonly diagnostic?: unknown;
72
74
  readonly attemptN?: number;
73
75
  readonly unavailable?: boolean;
74
76
  readonly retainRoute?: boolean;
@@ -78,7 +80,9 @@ export type WakeOutcome = {
78
80
  readonly diagnostic?: string;
79
81
  } | {
80
82
  readonly outcome: 'unknown';
81
- readonly diagnostic?: string;
83
+ readonly signature?: string;
84
+ readonly message?: string;
85
+ readonly diagnostic?: unknown;
82
86
  readonly attemptN?: number;
83
87
  };
84
88
  export interface SquareObservation {
@@ -49,6 +49,9 @@ export declare function withPathOutput(squarePath: string, body?: string, opts?:
49
49
  export declare function quoteShell(value: string): string;
50
50
  export declare function commandPrefix(squarePath: string): string;
51
51
  export declare function participantCommandPrefix(squarePath: string, name: string): string;
52
+ /** Blocked actions end with a full copy-pasteable recovery command. */
53
+ export declare function joinRecoveryCommand(squarePath: string, name: string): string;
54
+ export declare function participantsRecoveryCommand(squarePath: string): string;
52
55
  export declare function renderPresenceLines(participants: ParticipantStatus[], now: number, limit?: number): string[];
53
56
  export declare function expressHintLine(ownActivityCount: number): string | undefined;
54
57
  export declare function truncateChars(body: string, maxChars: number): {
@@ -41,6 +41,13 @@ export function commandPrefix(squarePath) {
41
41
  export function participantCommandPrefix(squarePath, name) {
42
42
  return `square --location ${quoteShell(path.resolve(squarePath))} --as ${quoteShell(name)}`;
43
43
  }
44
+ /** Blocked actions end with a full copy-pasteable recovery command. */
45
+ export function joinRecoveryCommand(squarePath, name) {
46
+ return `» ${participantCommandPrefix(squarePath, name)} join`;
47
+ }
48
+ export function participantsRecoveryCommand(squarePath) {
49
+ return `» ${commandPrefix(squarePath)} participants`;
50
+ }
44
51
  function formatAge(ms) {
45
52
  if (ms === undefined)
46
53
  return '(none)';
@@ -7,6 +7,16 @@ export interface WakeAttention {
7
7
  actIndex: number;
8
8
  recipient: string;
9
9
  }
10
+ export interface WakeReleaseDiagnostic {
11
+ readonly at: number;
12
+ readonly attention: WakeAttention;
13
+ readonly routeKind?: WakeRouteKind;
14
+ readonly attemptN?: number;
15
+ readonly session?: string;
16
+ readonly signature?: string;
17
+ readonly message?: string;
18
+ readonly diagnostic?: unknown;
19
+ }
10
20
  export type WakeAttempt = ProjectedWakeAttempt;
11
21
  export interface WakeDispatchLease {
12
22
  readonly leaseId: string;
@@ -36,6 +46,14 @@ export declare function readWakeAttempts(opts?: {
36
46
  now?: number;
37
47
  env?: NodeJS.ProcessEnv;
38
48
  }): Promise<WakeAttempt[]>;
49
+ export declare function redactWakeDiagnostic(value: unknown, env?: NodeJS.ProcessEnv): unknown;
50
+ export declare function readWakeReleaseDiagnostics(opts?: {
51
+ location?: string;
52
+ attention?: WakeAttention;
53
+ sessionId?: string;
54
+ now?: number;
55
+ env?: NodeJS.ProcessEnv;
56
+ }): Promise<WakeReleaseDiagnostic[]>;
39
57
  export declare function recordWakeAttempt(attempt: Omit<WakeAttempt, 'at'> & {
40
58
  at?: number;
41
59
  }, env?: NodeJS.ProcessEnv): Promise<WakeAttempt>;
@@ -5,6 +5,7 @@ import { canonicalSquarePath } from './registry.js';
5
5
  import { formatActivityId, parseActivityId } from './square-core.js';
6
6
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
7
7
  import { terminalWakeEvidence } from './square-projections.js';
8
+ import { redactDiagnostic } from './diagnostic-redaction.js';
8
9
  export { hasAttemptableWakeRoute, isWakeRouteAttemptable, terminalWakeEvidence } from './square-projections.js';
9
10
  const VALID_OUTCOMES = new Set(['accepted', 'unknown', 'failed']);
10
11
  function ledger(env) {
@@ -44,17 +45,43 @@ export async function readWakeAttempts(opts = {}) {
44
45
  const keys = await Promise.all(scoped.map((attempt) => wakeAttentionKey(attempt.attention)));
45
46
  return scoped.filter((_, index) => keys[index] === expected);
46
47
  }
47
- function redact(value, secret) {
48
- if (typeof value === 'string') {
49
- const withoutKnownSecret = secret ? value.split(secret).join('[redacted]') : value;
50
- return withoutKnownSecret.replace(/([?&]password=)[^&\s]+/gi, '$1[redacted]');
51
- }
52
- if (Array.isArray(value))
53
- return value.map((item) => redact(item, secret));
54
- if (value !== null && typeof value === 'object') {
55
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redact(item, secret)]));
48
+ export function redactWakeDiagnostic(value, env = process.env) {
49
+ return redactDiagnostic(value, env.PASEO_PASSWORD);
50
+ }
51
+ export async function readWakeReleaseDiagnostics(opts = {}) {
52
+ const now = opts.now ?? Date.now();
53
+ const env = opts.env ?? process.env;
54
+ const rows = await ledger(env).listEvidence({
55
+ kind: 'wake',
56
+ location: opts.location ?? opts.attention?.squarePath,
57
+ includeReleased: true,
58
+ now,
59
+ });
60
+ const expected = opts.attention === undefined ? undefined : await wakeAttentionKey(opts.attention);
61
+ const releases = [];
62
+ for (const row of rows) {
63
+ if (row.outcome !== 'released')
64
+ continue;
65
+ const actIndex = parseActivityId(row.activity);
66
+ if (actIndex === undefined || row.at === undefined)
67
+ continue;
68
+ if (opts.sessionId !== undefined && row.session !== opts.sessionId)
69
+ continue;
70
+ const attention = { squarePath: row.location, recipient: row.participant, actIndex };
71
+ if (expected !== undefined && JSON.stringify([row.location, formatActivityId(actIndex), nameKey(row.participant)]) !== expected)
72
+ continue;
73
+ releases.push({
74
+ at: row.at,
75
+ attention,
76
+ ...(row.routeKind === undefined ? {} : { routeKind: row.routeKind }),
77
+ ...(row.attemptN === undefined ? {} : { attemptN: row.attemptN }),
78
+ ...(row.session === undefined ? {} : { session: row.session }),
79
+ ...(row.signature === undefined ? {} : { signature: row.signature }),
80
+ ...(row.message === undefined ? {} : { message: redactWakeDiagnostic(row.message, env) }),
81
+ ...(row.diagnostic === undefined ? {} : { diagnostic: redactWakeDiagnostic(row.diagnostic, env) }),
82
+ });
56
83
  }
57
- return value;
84
+ return releases.sort((left, right) => right.at - left.at);
58
85
  }
59
86
  export async function recordWakeAttempt(attempt, env = process.env) {
60
87
  const value = { ...attempt, at: attempt.at ?? Date.now() };
@@ -63,7 +90,7 @@ export async function recordWakeAttempt(attempt, env = process.env) {
63
90
  if (value.outcome !== 'accepted' && !value.signature) {
64
91
  throw new Error(`${value.outcome} wake attempts require a transport signature.`);
65
92
  }
66
- const safe = redact(value, env.PASEO_PASSWORD);
93
+ const safe = redactWakeDiagnostic(value, env);
67
94
  const session = safe.session ?? safe.signature ?? `route:${safe.routeKind}`;
68
95
  const claim = await ledger(env).claimEvidence({ location: safe.attention.squarePath, participant: safe.attention.recipient, session, activity: formatActivityId(safe.attention.actIndex), kind: 'wake', leaseMs: 5000 });
69
96
  if (claim.status === 'acquired')
package/dist/watch.js CHANGED
@@ -6,7 +6,7 @@ import { closeOpenSquare } from './open-square.js';
6
6
  import { openParticipant } from './square-wiring.js';
7
7
  import { resolveParticipant, watchPresentation } from './views.js';
8
8
  import { acquireWatchLease, ownsWatchLease, pulseWatchLease, releaseWatchLease } from './wakes.js';
9
- import { renderWatchForceTakeover, renderWatchAlreadyActive, renderWatchReplaceMissing, renderWatchOutput, renderWatchReplaced, renderWatchStatus, participantCommandPrefix, withPathOutput, } from './presentation.js';
9
+ import { renderWatchForceTakeover, renderWatchAlreadyActive, renderWatchReplaceMissing, renderWatchOutput, renderWatchReplaced, renderWatchStatus, participantCommandPrefix, joinRecoveryCommand, participantsRecoveryCommand, withPathOutput, } from './presentation.js';
10
10
  import { hasAutomaticDeliveryIdentity } from './registry.js';
11
11
  import { parseActivityId } from './square-core.js';
12
12
  function watchStatusExitCode(status) {
@@ -150,6 +150,18 @@ export async function cmdWatch(squarePath, name, opts) {
150
150
  try {
151
151
  square = await openSquare(squarePath, { clock: nowMs });
152
152
  name = (await resolveParticipant(square, name)).name;
153
+ }
154
+ catch (err) {
155
+ if (isSquareError(err)) {
156
+ process.stderr.write(err.message + '\n');
157
+ // The caller's own name has never joined; only a join admits it.
158
+ if (/^Unknown participant/.test(err.message))
159
+ process.stderr.write(`${joinRecoveryCommand(squarePath, name)}\n`);
160
+ process.exit(err.code === 'not_found' ? 1 : 2);
161
+ }
162
+ throw err;
163
+ }
164
+ try {
153
165
  if (opts.mention !== undefined)
154
166
  opts = { ...opts, mention: (await resolveParticipant(square, opts.mention)).name };
155
167
  if (opts.participants !== undefined && opts.participants.length > 0) {
@@ -157,8 +169,12 @@ export async function cmdWatch(squarePath, name, opts) {
157
169
  }
158
170
  }
159
171
  catch (err) {
172
+ await closeOpenSquare(square).catch(() => undefined);
160
173
  if (isSquareError(err)) {
161
174
  process.stderr.write(err.message + '\n');
175
+ // A filter target is unknown, not the caller; the roster is the useful next read.
176
+ if (/^(Unknown participant|Unknown mention target)/.test(err.message))
177
+ process.stderr.write(`${participantsRecoveryCommand(squarePath)}\n`);
162
178
  process.exit(err.code === 'not_found' ? 1 : 2);
163
179
  }
164
180
  throw err;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.56",
3
+ "version": "0.3.58",
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": {