@astrosheep/square 0.3.57 → 0.3.61

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,15 +1,16 @@
1
1
  import { cmdActivity } from '../activity.js';
2
2
  import { formatActivityId, formatHardCap, parseActivityId, } from '../model.js';
3
- import { participantCommandPrefix, participantIdentity, quoteShell, renderEventCli, renderAmbientEvent, withPathOutput, } from '../presentation.js';
3
+ import { formatRefusal, participantCommandPrefix, participantIdentity, quoteShell, renderRoomChangeText, renderAmbientEvent, withPathOutput, } from '../presentation.js';
4
4
  import { claimSessionParticipant, hasAutomaticDeliveryIdentity, localSessionIdentities, lookupParticipant, readParticipantOwner, recordSessionDone, } from '../registry.js';
5
5
  import { createHostLedgerPort } from '../host-ledger-file-adapter.js';
6
6
  import { sessionIdsFromEnvironment } from '../square-projections.js';
7
- import { nowMs } from '../runtime.js';
7
+ import { actId, nowMs } from '../runtime.js';
8
8
  import { createSquare, openSquare } from '../square-file-adapter.js';
9
9
  import { closeOpenSquare } from '../open-square.js';
10
10
  import { openParticipant, Square } from '../square-wiring.js';
11
11
  import { entryPresentation, eventPresentation } from '../views.js';
12
12
  import { createDefaultWakeTransport } from '../notifications.js';
13
+ import { style } from '../tty-style.js';
13
14
  import { fail, parseBoundedLimit, parseHardCap, parsePositiveInteger, readStdin, requireParticipant, requireSquarePath, requireValue, resolveBody, usage, } from './context.js';
14
15
  const JOIN_DEFAULT_LAST = 10;
15
16
  const JOIN_MAX_LAST = 100;
@@ -57,7 +58,7 @@ export const buildCommand = {
57
58
  await createSquare(squarePath, intent.options, snippet);
58
59
  const cap = intent.options.hardCap === null ? 'unlimited' : formatHardCap(intent.options.hardCap);
59
60
  const throttle = intent.options.throttlePerMinute === undefined ? [] : [` · throttle ${intent.options.throttlePerMinute}/min`];
60
- return withPathOutput(squarePath, ['✓ built', ` · cap ${cap}`, ...throttle, ' · participants (none seeded — first join adds names)'].join('\n'), { participantCount: 0 });
61
+ return withPathOutput(squarePath, ['✓ the square is open', ` · cap ${cap}`, ...throttle, ' · nobody here yet — the first join steps in'].join('\n'), { participantCount: 0 });
61
62
  },
62
63
  present: (result) => process.stdout.write(result),
63
64
  };
@@ -95,12 +96,13 @@ export const joinCommand = {
95
96
  const reconnect = before.joined && identity !== undefined && owner?.sessionId === identity.sessionId;
96
97
  const isRejoin = before.joined;
97
98
  if (isRejoin && !intent.kick && !reconnect) {
98
- fail([
99
+ process.stderr.write(formatRefusal(squarePath, [
99
100
  `✕ ${participantIdentity(intent.name)} shoos you out of the square`,
100
101
  ` · a same-named participant stands here — the name is taken`,
101
- ` · --kick banishes her and the name becomes yours`,
102
- `» ${participantCommandPrefix(squarePath, intent.name)} join --kick`,
103
- ].join('\n'));
102
+ ` · --kick banishes the one standing there — the name becomes yours`,
103
+ `${participantCommandPrefix(squarePath, intent.name)} join --kick`,
104
+ ], { participantCount: before.participantCount }));
105
+ process.exit(2);
104
106
  }
105
107
  const takeoverNeeded = isRejoin && intent.kick;
106
108
  const oldBindings = takeoverNeeded ? await lookupParticipant(squarePath, intent.name) : [];
@@ -134,20 +136,20 @@ export const joinCommand = {
134
136
  const contextText = after.joinContext;
135
137
  const fallback = hasAutomaticDeliveryIdentity()
136
138
  ? []
137
- : ['', `» ${participantCommandPrefix(squarePath, joinedName)} catch --idle 30m`, ' no session delivery detected — keep this catch open for new activity'];
139
+ : ['', `${participantCommandPrefix(squarePath, joinedName)} catch --idle 30m`, ' the square has no way to call you — keep this catch open and stay within earshot'];
138
140
  const scene = after.scene;
139
141
  const entryLine = !isRejoin
140
- ? '● You stepped into the square'
142
+ ? '● you stepped into the square'
141
143
  : reconnect && !intent.kick
142
144
  ? '● you are already in the square'
143
145
  : `✓ you banished the original ${participantIdentity(joinedName)} — the name is yours`;
144
146
  const output = [
145
147
  entryLine,
146
148
  '',
147
- "Don't treat Square as a public dumping ground. Speak here only when you genuinely think someone else needs to know; otherwise you are needlessly interrupting them.",
149
+ "· carved into the fountain's edge: every word here lands on a real ear — speak when someone needs it.",
148
150
  ...(reconnect || scene === '' ? [] : ['', scene]),
149
- ...(isRejoin || contextText === '' ? [] : ['', 'context', contextText]),
150
- ...(isRejoin || activities === '' ? [] : ['', 'recent activity', activities]),
151
+ ...(isRejoin || contextText === '' ? [] : ['', style('dim', 'context'), contextText]),
152
+ ...(isRejoin || activities === '' ? [] : ['', style('dim', 'recent activity'), activities]),
151
153
  ...fallback,
152
154
  ].join('\n');
153
155
  return withPathOutput(squarePath, output, { participantCount: after.participantCount });
@@ -173,7 +175,7 @@ function parseActivity(argv, context) {
173
175
  else if (argument === '--no-wait')
174
176
  noWait = true;
175
177
  else if (argument === '--beside')
176
- fail('✕ express does not know --beside\n» square express --help');
178
+ fail('✕ express does not know --beside\nsquare express --help');
177
179
  else if (argument === '--bell')
178
180
  bell = true;
179
181
  else if (argument === '--no-mention')
@@ -194,11 +196,11 @@ function parseActivity(argv, context) {
194
196
  }
195
197
  const reach = bell ? 'bell' : undefined;
196
198
  if (bell && (noMention || mentions.length > 0))
197
- fail('✕ --bell cannot be combined with --mention or --no-mention\n» square express --help');
199
+ fail('✕ --bell cannot be combined with --mention or --no-mention\nsquare express --help');
198
200
  if (noMention && mentions.length > 0)
199
- fail('✕ --no-mention cannot be combined with --mention\n» square express --help');
201
+ fail('✕ --no-mention cannot be combined with --mention\nsquare express --help');
200
202
  if (!bell && !noMention && mentions.length === 0)
201
- fail('✕ express needs --mention <name>, --no-mention, or --bell\n» square express --help');
203
+ fail('✕ express needs --mention <name>, --no-mention, or --bell\nsquare express --help');
202
204
  if (bodyArgs.length !== 1) {
203
205
  if (bodyArgs.length === 0) {
204
206
  if (!process.stdin.isTTY)
@@ -320,7 +322,7 @@ export const doneCommand = {
320
322
  const name = result.activity.actor;
321
323
  const presentation = await openSquare(squarePath, { clock: nowMs });
322
324
  const participantCount = (await entryPresentation(presentation, name).finally(() => closeOpenSquare(presentation))).participantCount;
323
- return withPathOutput(squarePath, `○ ${participantIdentity(name)} steps out of the square — done · just now`, { participantCount });
325
+ return withPathOutput(squarePath, `○ ${participantIdentity(name)} steps out of the square — done · ${result.activity.id} · just now`, { participantCount });
324
326
  },
325
327
  present: (result) => process.stdout.write(result),
326
328
  };
@@ -341,7 +343,7 @@ export const holdCommand = {
341
343
  const presentationSquare = await openSquare(squarePath, { clock: nowMs });
342
344
  const presentation = await eventPresentation(presentationSquare, result.activity.id);
343
345
  await closeOpenSquare(presentationSquare);
344
- return withPathOutput(squarePath, renderEventCli(presentation.activity), { participantCount: presentation.participantCount, held: true });
346
+ return withPathOutput(squarePath, `· ${renderRoomChangeText(presentation.activity)} · ${actId(presentation.activity.index)} · just now`, { participantCount: presentation.participantCount, held: true });
345
347
  }
346
348
  finally {
347
349
  await square.close();
@@ -365,7 +367,7 @@ export const resumeCommand = {
365
367
  const presentationSquare = await openSquare(squarePath, { clock: nowMs });
366
368
  const presentation = await eventPresentation(presentationSquare, result.activity.id);
367
369
  await closeOpenSquare(presentationSquare);
368
- return withPathOutput(squarePath, renderEventCli(presentation.activity), { participantCount: presentation.participantCount });
370
+ return withPathOutput(squarePath, `✓ ${renderRoomChangeText(presentation.activity)} · ${actId(presentation.activity.index)} · just now`, { participantCount: presentation.participantCount });
369
371
  }
370
372
  finally {
371
373
  await square.close();
@@ -58,6 +58,8 @@ export type ActDecision = {
58
58
  type: 'bell_quota';
59
59
  nextAt: number;
60
60
  };
61
+ /** The unread activity a viewer has not caught up to, grouped by speaker. */
62
+ export declare function unreadActivitySummaries(squareState: SquareState, name: string, now: number): UnreadActivitySummary[];
61
63
  export declare function decideAct(squareState: SquareState, input: {
62
64
  name: string;
63
65
  body: string;
package/dist/decisions.js CHANGED
@@ -9,7 +9,7 @@ export function resolveKnownName(squareState, name) {
9
9
  validateName(name);
10
10
  const known = resolveRosterName(squareState, name);
11
11
  if (known === undefined) {
12
- throw new SquareError('invalid_args', `Unknown participant "${participantIdentity(name)}".`);
12
+ throw new SquareError('invalid_args', `✕ ${participantIdentity(name)} has never stepped into this square`);
13
13
  }
14
14
  return known;
15
15
  }
@@ -66,6 +66,40 @@ function requireStanding(squareState, act) {
66
66
  throw new Error(`Unexpected standing validation result: ${result.reason}`);
67
67
  }
68
68
  const UNREAD_PREVIEW_LIMIT = 3;
69
+ /** The unread activity a viewer has not caught up to, grouped by speaker. */
70
+ export function unreadActivitySummaries(squareState, name, now) {
71
+ const delivery = deriveDeliveryModel(squareState);
72
+ const delta = actDelta(squareState.acts, delivery.cursorFor(name));
73
+ const unreadPublic = directedPeerSays(squareState, delta, name, delivery);
74
+ const sayCountByActor = new Map();
75
+ const unreadByParticipant = new Map();
76
+ for (const item of unreadPublic) {
77
+ if (item.kind === 'say') {
78
+ const key = item.actor.toLocaleLowerCase();
79
+ sayCountByActor.set(key, (sayCountByActor.get(key) ?? 0) + 1);
80
+ }
81
+ if (sameName(item.actor, name))
82
+ continue;
83
+ const actorKey = item.actor.toLocaleLowerCase();
84
+ const currentSummary = unreadByParticipant.get(item.actor);
85
+ unreadByParticipant.set(item.actor, {
86
+ count: (currentSummary?.count ?? 0) + 1,
87
+ latestAt: currentSummary === undefined ? item.at : Math.max(currentSummary.latestAt, item.at),
88
+ previews: [
89
+ ...(currentSummary?.previews ?? []),
90
+ { number: sayCountByActor.get(actorKey) ?? 1, act: item, perception: delivery.perceive(item, name) },
91
+ ].slice(-UNREAD_PREVIEW_LIMIT),
92
+ });
93
+ }
94
+ return [...unreadByParticipant.entries()]
95
+ .map(([participant, summary]) => ({
96
+ name: participant,
97
+ count: summary.count,
98
+ latestActivityAgeMs: Math.max(0, now - summary.latestAt),
99
+ previews: summary.previews,
100
+ }))
101
+ .sort((a, b) => a.latestActivityAgeMs - b.latestActivityAgeMs || a.name.localeCompare(b.name));
102
+ }
69
103
  export function decideAct(squareState, input) {
70
104
  const { now, force } = input;
71
105
  const name = resolveStandingName(squareState, input.name);
@@ -81,7 +115,7 @@ export function decideAct(squareState, input) {
81
115
  validateName(requested);
82
116
  const resolved = joinedNames.find((candidate) => sameName(candidate, requested));
83
117
  if (resolved === undefined) {
84
- throw new SquareError('invalid_args', `Unknown mention target ${participantIdentity(requested)}.`);
118
+ throw new SquareError('invalid_args', `✕ ${participantIdentity(requested)} is not standing in this square`);
85
119
  }
86
120
  if (!mentionNames.some((existing) => sameName(existing, resolved))) {
87
121
  if (mentionNames.length >= MAX_IDENTITY_SET_SIZE) {
@@ -126,34 +160,7 @@ export function decideAct(squareState, input) {
126
160
  const delta = actDelta(squareState.acts, delivery.cursorFor(name));
127
161
  const unreadPublic = directedPeerSays(squareState, delta, name, delivery);
128
162
  const unreadRoomChanges = [];
129
- const sayCountByActor = new Map();
130
- const unreadByParticipant = new Map();
131
- for (const item of unreadPublic) {
132
- if (item.kind === 'say') {
133
- const key = item.actor.toLocaleLowerCase();
134
- sayCountByActor.set(key, (sayCountByActor.get(key) ?? 0) + 1);
135
- }
136
- if (sameName(item.actor, name))
137
- continue;
138
- const actorKey = item.actor.toLocaleLowerCase();
139
- const currentSummary = unreadByParticipant.get(item.actor);
140
- unreadByParticipant.set(item.actor, {
141
- count: (currentSummary?.count ?? 0) + 1,
142
- latestAt: currentSummary === undefined ? item.at : Math.max(currentSummary.latestAt, item.at),
143
- previews: [
144
- ...(currentSummary?.previews ?? []),
145
- { number: sayCountByActor.get(actorKey) ?? 1, act: item, perception: delivery.perceive(item, name) },
146
- ].slice(-UNREAD_PREVIEW_LIMIT),
147
- });
148
- }
149
- const activitySummaries = [...unreadByParticipant.entries()]
150
- .map(([participant, summary]) => ({
151
- name: participant,
152
- count: summary.count,
153
- latestActivityAgeMs: Math.max(0, now - summary.latestAt),
154
- previews: summary.previews,
155
- }))
156
- .sort((a, b) => a.latestActivityAgeMs - b.latestActivityAgeMs || a.name.localeCompare(b.name));
163
+ const activitySummaries = unreadActivitySummaries(squareState, name, now);
157
164
  const latestActivityAgeMs = activitySummaries[0]?.latestActivityAgeMs;
158
165
  const hasUnread = unreadPublic.length > 0;
159
166
  const hasFreshUnreadActivity = latestActivityAgeMs !== undefined && latestActivityAgeMs <= UNREAD_BLOCK_GRACE_MS;
@@ -168,7 +175,7 @@ export function decideAct(squareState, input) {
168
175
  ...(reach !== undefined ? { reach } : {}),
169
176
  ...(reply !== undefined ? { reply } : {}),
170
177
  },
171
- confirmation: `● heads turn your way — #${ownActCount}`,
178
+ confirmation: `● your activity lands — #${ownActCount}`,
172
179
  ownActCount,
173
180
  pendingPublic: unreadPublic,
174
181
  pendingRoomChanges: unreadRoomChanges,
@@ -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;