@astrosheep/square 0.3.62 → 0.3.64

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.
Files changed (168) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +3 -2
  2. package/claude-plugin/hooks/hooks.json +1 -22
  3. package/claude-plugin/hooks/register.js +119 -0
  4. package/claude-plugin/types.d.ts +7 -0
  5. package/codex-plugin/.codex-plugin/plugin.json +1 -1
  6. package/dist/activity-feed.d.ts +1 -10
  7. package/dist/activity-feed.js +1 -27
  8. package/dist/activity.js +57 -25
  9. package/dist/artifact.js +12 -24
  10. package/dist/attention-presentation.js +1 -0
  11. package/dist/automatic-session.d.ts +3 -1
  12. package/dist/automatic-session.js +21 -34
  13. package/dist/boundary-presentation.js +4 -13
  14. package/dist/canonical-path.d.ts +11 -0
  15. package/dist/canonical-path.js +47 -0
  16. package/dist/catch-decisions.js +14 -10
  17. package/dist/catch-selection.d.ts +6 -0
  18. package/dist/catch-selection.js +14 -0
  19. package/dist/claude-delivery.d.ts +11 -0
  20. package/dist/claude-delivery.js +145 -0
  21. package/dist/claude-mod.d.ts +8 -0
  22. package/dist/claude-mod.js +164 -0
  23. package/dist/cli/context.d.ts +0 -1
  24. package/dist/cli/context.js +9 -21
  25. package/dist/cli/harness-command.d.ts +0 -1
  26. package/dist/cli/harness-command.js +0 -6
  27. package/dist/cli/observation-commands.d.ts +1 -1
  28. package/dist/cli/observation-commands.js +31 -58
  29. package/dist/cli/program.js +20 -18
  30. package/dist/cli/registry.js +2 -2
  31. package/dist/cli/square-commands.js +10 -6
  32. package/dist/codex-hook.js +9 -12
  33. package/dist/codex-queue.d.ts +2 -2
  34. package/dist/codex-queue.js +7 -5
  35. package/dist/decisions.d.ts +2 -6
  36. package/dist/decisions.js +16 -17
  37. package/dist/delivery-health.d.ts +1 -2
  38. package/dist/delivery-health.js +4 -5
  39. package/dist/delivery-operations.d.ts +2 -5
  40. package/dist/delivery-operations.js +77 -214
  41. package/dist/delivery.d.ts +25 -47
  42. package/dist/delivery.js +14 -46
  43. package/dist/diagnostic-redaction.js +7 -9
  44. package/dist/file-changes.d.ts +0 -1
  45. package/dist/file-changes.js +2 -19
  46. package/dist/harness-claude.js +8 -6
  47. package/dist/harness-codex.d.ts +0 -2
  48. package/dist/harness-codex.js +0 -2
  49. package/dist/harness-links.js +13 -17
  50. package/dist/harness.js +3 -7
  51. package/dist/help.js +4 -3
  52. package/dist/host-ledger-file-adapter.d.ts +17 -8
  53. package/dist/host-ledger-file-adapter.js +164 -80
  54. package/dist/host-ledger.d.ts +55 -50
  55. package/dist/inbox.d.ts +0 -3
  56. package/dist/inbox.js +13 -30
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.js +1 -1
  59. package/dist/list.js +1 -4
  60. package/dist/mcp.js +18 -40
  61. package/dist/mention-parse.js +3 -9
  62. package/dist/model.d.ts +11 -2
  63. package/dist/model.js +1 -1
  64. package/dist/notifications.d.ts +3 -16
  65. package/dist/notifications.js +41 -122
  66. package/dist/opencode-delivery.d.ts +14 -0
  67. package/dist/opencode-delivery.js +171 -0
  68. package/dist/opencode.d.ts +39 -18
  69. package/dist/opencode.js +145 -40
  70. package/dist/packages/agent-delivery/src/claude-auth.d.ts +13 -0
  71. package/dist/packages/agent-delivery/src/claude-auth.js +70 -0
  72. package/dist/packages/agent-delivery/src/claude-native.d.ts +28 -0
  73. package/dist/packages/agent-delivery/src/claude-native.js +65 -0
  74. package/dist/packages/agent-delivery/src/claude.d.ts +14 -0
  75. package/dist/packages/agent-delivery/src/claude.js +66 -0
  76. package/dist/packages/agent-delivery/src/deadline.d.ts +11 -0
  77. package/dist/packages/agent-delivery/src/deadline.js +41 -0
  78. package/dist/packages/agent-delivery/src/errors.d.ts +16 -0
  79. package/dist/packages/agent-delivery/src/errors.js +41 -0
  80. package/dist/packages/agent-delivery/src/guards.d.ts +6 -0
  81. package/dist/packages/agent-delivery/src/guards.js +6 -0
  82. package/dist/packages/agent-delivery/src/index.d.ts +83 -0
  83. package/dist/packages/agent-delivery/src/index.js +110 -0
  84. package/dist/packages/agent-delivery/src/local-endpoint.d.ts +26 -0
  85. package/dist/packages/agent-delivery/src/local-endpoint.js +114 -0
  86. package/dist/packages/agent-delivery/src/opencode-native.d.ts +33 -0
  87. package/dist/packages/agent-delivery/src/opencode-native.js +75 -0
  88. package/dist/packages/agent-delivery/src/opencode.d.ts +14 -0
  89. package/dist/packages/agent-delivery/src/opencode.js +127 -0
  90. package/dist/packages/agent-delivery/src/paseo-native.d.ts +58 -0
  91. package/dist/packages/agent-delivery/src/paseo-native.js +122 -0
  92. package/dist/packages/agent-delivery/src/paseo.d.ts +19 -0
  93. package/dist/packages/agent-delivery/src/paseo.js +106 -0
  94. package/dist/packages/agent-delivery/src/pi-client.d.ts +10 -0
  95. package/dist/packages/agent-delivery/src/pi-client.js +106 -0
  96. package/dist/packages/agent-delivery/src/pi-protocol.d.ts +15 -0
  97. package/dist/packages/agent-delivery/src/pi-protocol.js +50 -0
  98. package/dist/packages/agent-delivery/src/pi-send.d.ts +23 -0
  99. package/dist/packages/agent-delivery/src/pi-send.js +4 -0
  100. package/dist/packages/agent-delivery/src/pi.d.ts +25 -0
  101. package/dist/packages/agent-delivery/src/pi.js +209 -0
  102. package/dist/packages/agent-delivery/src/types.d.ts +120 -0
  103. package/dist/participant-host.js +12 -13
  104. package/dist/participant-identity.d.ts +21 -0
  105. package/dist/participant-identity.js +32 -11
  106. package/dist/paseo-delivery.d.ts +2 -2
  107. package/dist/paseo-delivery.js +35 -27
  108. package/dist/paseo-timeline.js +25 -21
  109. package/dist/paseo.d.ts +2 -2
  110. package/dist/paseo.js +1 -2
  111. package/dist/ports.d.ts +7 -20
  112. package/dist/presentation-operations.js +16 -4
  113. package/dist/presentation.d.ts +19 -10
  114. package/dist/presentation.js +99 -177
  115. package/dist/registry.d.ts +15 -66
  116. package/dist/registry.js +45 -123
  117. package/dist/routes.d.ts +1 -28
  118. package/dist/routes.js +22 -104
  119. package/dist/runtime.d.ts +3 -9
  120. package/dist/runtime.js +14 -67
  121. package/dist/search.d.ts +0 -2
  122. package/dist/search.js +3 -10
  123. package/dist/square-actions.js +48 -72
  124. package/dist/square-application.js +25 -23
  125. package/dist/square-core.d.ts +3 -0
  126. package/dist/square-core.js +22 -5
  127. package/dist/square-facade.d.ts +2 -0
  128. package/dist/square-file-adapter.js +4 -7
  129. package/dist/square-projections.d.ts +40 -45
  130. package/dist/square-projections.js +43 -73
  131. package/dist/square-state.js +1 -4
  132. package/dist/square-storage.d.ts +5 -8
  133. package/dist/square-storage.js +13 -36
  134. package/dist/square-wiring.d.ts +0 -5
  135. package/dist/square-wiring.js +3 -21
  136. package/dist/stream.js +4 -6
  137. package/dist/time.js +2 -7
  138. package/dist/views.d.ts +4 -16
  139. package/dist/views.js +12 -19
  140. package/dist/wake-attempts.d.ts +2 -56
  141. package/dist/wake-attempts.js +15 -110
  142. package/dist/wake-eligibility.d.ts +43 -0
  143. package/dist/wake-eligibility.js +106 -0
  144. package/dist/wake-evidence.d.ts +32 -9
  145. package/dist/wake-evidence.js +29 -24
  146. package/dist/wake-sink.d.ts +13 -9
  147. package/dist/wake-sink.js +35 -54
  148. package/dist/watch.js +13 -21
  149. package/extensions/square-pi.js +10 -5
  150. package/guides/architect.md +77 -77
  151. package/guides/brainstorm.md +197 -197
  152. package/guides/claude-native.md +101 -0
  153. package/guides/host-ledger.md +3 -1
  154. package/guides/participant.md +14 -7
  155. package/guides/pi-native-delivery.md +43 -0
  156. package/package.json +4 -3
  157. package/dist/claude-hook.d.ts +0 -10
  158. package/dist/claude-hook.js +0 -60
  159. package/dist/paseo-connection.d.ts +0 -15
  160. package/dist/paseo-connection.js +0 -141
  161. package/dist/presence.d.ts +0 -2
  162. package/dist/presence.js +0 -24
  163. package/dist/presented.d.ts +0 -2
  164. package/dist/presented.js +0 -10
  165. package/dist/state-cell.d.ts +0 -14
  166. package/dist/wake-port.d.ts +0 -23
  167. package/dist/wake-port.js +0 -27
  168. /package/dist/{state-cell.js → packages/agent-delivery/src/types.js} +0 -0
@@ -1,25 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { canonicalPath } from './canonical-path.js';
3
4
  /** Filesystem notifications are hints, never proof of a committed change. */
4
5
  const RECONCILE_MS = 2_000;
5
- export async function canonicalFilePath(value) {
6
- let current = path.resolve(value);
7
- const suffix = [];
8
- for (;;) {
9
- try {
10
- return path.join(await fs.promises.realpath(current), ...suffix.reverse());
11
- }
12
- catch (error) {
13
- if (error.code !== 'ENOENT')
14
- return path.resolve(value);
15
- const parent = path.dirname(current);
16
- if (parent === current)
17
- return path.resolve(value);
18
- suffix.push(path.basename(current));
19
- current = parent;
20
- }
21
- }
22
- }
23
6
  /** A local edge counter: register first, inspect state, then wait from that counter. */
24
7
  class Edges {
25
8
  version = 0;
@@ -144,7 +127,7 @@ async function abortable(promise, signal) {
144
127
  const versions = new Map();
145
128
  /** Shared only within this process. Durable state is always read at the storage boundary. */
146
129
  export async function observeFileVersion(filePath, kind, read) {
147
- const file = await canonicalFilePath(filePath);
130
+ const file = await canonicalPath(filePath);
148
131
  const key = kind + '\0' + file;
149
132
  let shared = versions.get(key);
150
133
  if (shared === undefined) {
@@ -27,10 +27,6 @@ function requireSuccess(result, operation, allowMissing = false) {
27
27
  return;
28
28
  throw new Error(`Claude ${operation} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
29
29
  }
30
- function writeJson(file, value) {
31
- fs.mkdirSync(path.dirname(file), { recursive: true });
32
- fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
33
- }
34
30
  export async function installClaudePlugin(homeDir, run = runClaude) {
35
31
  const marketplaceRoot = claudeMarketplaceRoot(homeDir);
36
32
  const staged = stageReplacement(marketplaceRoot, (stage) => {
@@ -39,11 +35,13 @@ export async function installClaudePlugin(homeDir, run = runClaude) {
39
35
  const skill = path.join(plugin, 'skills', 'square', 'SKILL.md');
40
36
  fs.mkdirSync(path.dirname(skill), { recursive: true });
41
37
  fs.copyFileSync(path.join(fileURLToPath(new URL('../skills/square/', import.meta.url)), 'SKILL.md'), skill);
42
- writeJson(path.join(stage, '.claude-plugin', 'marketplace.json'), {
38
+ const manifestPath = path.join(stage, '.claude-plugin', 'marketplace.json');
39
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
40
+ fs.writeFileSync(manifestPath, `${JSON.stringify({
43
41
  name: CLAUDE_MARKETPLACE_NAME,
44
42
  owner: { name: 'Square' },
45
43
  plugins: [{ name: SQUARE_IDENTITY.pluginName, source: './plugins/square' }],
46
- });
44
+ }, null, 2)}\n`, { mode: 0o600 });
47
45
  });
48
46
  try {
49
47
  const add = run(homeDir, ['plugin', 'marketplace', 'add', marketplaceRoot]);
@@ -73,5 +71,9 @@ export async function doctorClaudePlugin(homeDir, run = runClaude) {
73
71
  return [
74
72
  fs.existsSync(bundle) ? `✓ Square Claude plugin bundle ${root}` : `○ Square Claude plugin bundle missing ${root}`,
75
73
  installed ? `✓ ${CLAUDE_PLUGIN_ID} installed` : `○ ${CLAUDE_PLUGIN_ID} unavailable`,
74
+ `Claude native inbox: validated on macOS Claude Code 2.1.295 with a trusted, approved interactive mod.`,
75
+ `Installation or a socket alone does not prove receiver availability; absent/disabled mods, headless unapproved mods, other builds/platforms are unavailable.`,
76
+ `Queue admission is not model processing. Queued or held native messages cannot be retracted by Square cancellation.`,
77
+ `Live routes and admission/presentation evidence: square harness doctor delivery. No legacy fallback.`,
76
78
  ];
77
79
  }
@@ -1,5 +1,3 @@
1
- export declare const CODEX_HOOK_COMMAND: "square codex-hook";
2
- export declare const SQUARE_CODEX_MARKER: "square-codex-hook";
3
1
  export declare const CODEX_PLUGIN_ID: "square@astrosheep";
4
2
  export declare const CODEX_MARKETPLACE_NAME: "astrosheep";
5
3
  export interface CodexCommandResult {
@@ -4,8 +4,6 @@ import { fileURLToPath } from 'node:url';
4
4
  import crossSpawn from 'cross-spawn';
5
5
  import { SQUARE_IDENTITY } from './identity.js';
6
6
  import { stageReplacement } from './harness-stage.js';
7
- export const CODEX_HOOK_COMMAND = SQUARE_IDENTITY.hookCommand;
8
- export const SQUARE_CODEX_MARKER = SQUARE_IDENTITY.hookMarker;
9
7
  export const CODEX_PLUGIN_ID = SQUARE_IDENTITY.pluginId;
10
8
  export const CODEX_MARKETPLACE_NAME = SQUARE_IDENTITY.marketplaceName;
11
9
  const LEGACY_MARKETPLACES = ['astrosheep-square'];
@@ -9,22 +9,21 @@ function packageRoot() {
9
9
  // Emitted modules live in dist; package assets are one level above them.
10
10
  return fileURLToPath(new URL('../', import.meta.url));
11
11
  }
12
- function lstatMaybe(target) {
13
- try {
14
- return fs.lstatSync(target);
15
- }
16
- catch (error) {
17
- if (error.code === 'ENOENT')
18
- return undefined;
19
- throw error;
20
- }
21
- }
22
12
  export function installHarnessLinks(links, force = false) {
23
13
  const prepared = links.map((link) => {
24
14
  if (!fs.existsSync(link.source)) {
25
15
  throw new Error(`Harness link source is missing: ${link.source}`);
26
16
  }
27
- const existing = lstatMaybe(link.target);
17
+ const existing = (() => {
18
+ try {
19
+ return fs.lstatSync(link.target);
20
+ }
21
+ catch (error) {
22
+ if (error.code === 'ENOENT')
23
+ return undefined;
24
+ throw error;
25
+ }
26
+ })();
28
27
  return {
29
28
  ...link,
30
29
  existing,
@@ -94,16 +93,13 @@ function runOpenCode(homeDir, args) {
94
93
  throw result.error;
95
94
  return { status: result.status ?? 1, stdout: result.stdout || '', stderr: result.stderr || '' };
96
95
  }
97
- function requireOpenCodeSuccess(result, action) {
98
- if (result.status === 0)
99
- return;
100
- throw new Error(`OpenCode ${action} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
101
- }
102
96
  export function installOpenCodePlugin(homeDir, force = false, run = runOpenCode) {
103
97
  const args = ['plugin', SQUARE_IDENTITY.packageName, '--global'];
104
98
  if (force)
105
99
  args.push('--force');
106
- requireOpenCodeSuccess(run(homeDir, args), 'plugin install');
100
+ const result = run(homeDir, args);
101
+ if (result.status !== 0)
102
+ throw new Error(`OpenCode plugin install failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
107
103
  return [SQUARE_IDENTITY.packageName];
108
104
  }
109
105
  function configPath(homeDir) {
package/dist/harness.js CHANGED
@@ -97,14 +97,10 @@ const TARGETS = [
97
97
  export function harnessTargets() {
98
98
  return TARGETS;
99
99
  }
100
- function requireHarnessCapability(target, action) {
101
- const found = TARGETS.find((candidate) => candidate.name === target);
102
- if (found === undefined || !found.capabilities.includes(action))
103
- throw new Error(`Unsupported harness capability: ${action} ${target}`);
104
- return found;
105
- }
106
100
  export async function executeHarnessTarget(targetName, action, context) {
107
- const target = requireHarnessCapability(targetName, action);
101
+ const target = TARGETS.find((candidate) => candidate.name === targetName);
102
+ if (target === undefined || !target.capabilities.includes(action))
103
+ throw new Error(`Unsupported harness capability: ${action} ${targetName}`);
108
104
  const capability = target[action];
109
105
  if (capability === undefined)
110
106
  throw new Error(`Unsupported harness capability: ${action} ${targetName}`);
package/dist/help.js CHANGED
@@ -17,9 +17,9 @@ const COMMANDS = [
17
17
  details: ['Options:', ' --last <N> Show the last N public activities (default 10, maximum 100).', ' --kick End the standing participant and reclaim the name for this session.'],
18
18
  },
19
19
  {
20
- names: ['express'], usage: '--as <name> express [-f|--force] [--no-wait] (--mention <name>... | --no-mention | --bell) [--reply <activity-id>] <activity | ->', usesSquare: true, group: 'participant',
20
+ names: ['express'], usage: '--as <name> express [-f|--force] [--no-wait] (--mention <name>... | --no-mention | --bell | --reply <activity-id>) <activity | ->', usesSquare: true, group: 'participant',
21
21
  summary: 'Speak, gesture, or do both.',
22
- details: ['Reach:', ' --mention <name> Address one participant; repeat for multiple participants. The body stays unchanged.', ' --no-mention Land a bare activity explicitly; @names stay plain text. listen opts someone into future bare delivery.', " --bell Call every participant's attention to this activity.", ' @name An @name in the body also addresses that standing participant; `backticks` keep @text literal.', '', 'Options:', ' -f, --force Express without first catching unread activity or attention etiquette.', ' --no-wait If held or throttled, save a draft and return instead of waiting. A held wait gives up on its own after 10 minutes.', ' --reply <activity-id> Mark this activity as a reply to an earlier activity (for example act/12).'],
22
+ details: ['Reach:', ' --mention <name> Address one participant; repeat for multiple participants. The body stays unchanged.', ' --no-mention Land a bare activity explicitly; @names stay plain text. listen opts someone into future bare delivery.', " --bell Call every participant's attention to this activity.", ' @name An @name in the body also addresses that standing participant; `backticks` keep @text literal.', '', 'Options:', ' -f, --force Express without first catching unread activity or attention etiquette.', ' --no-wait If held or throttled, save a draft and return instead of waiting. A held wait gives up on its own after 10 minutes.', ' --reply <activity-id> Mark this activity as a reply to an earlier activity (for example act/12); it reaches that activity\'s author.'],
23
23
  },
24
24
  { names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant\'s future bare says.' },
25
25
  { names: ['ignore'], usage: '--as <name> ignore <participant>', usesSquare: true, group: 'participant', summary: 'Ignore one participant\'s future mentions and bare says.' },
@@ -40,7 +40,8 @@ const COMMANDS = [
40
40
  summary: 'Inspect a bounded machine-local notification snapshot for a native session.',
41
41
  details: ['Options:', ' --for-session <id> Required harness session id.', ' --limit <N> Snapshot memberships (default 20, maximum 100).', ' --json Emit the bounded snapshot as structured JSON.'],
42
42
  },
43
- { names: ['claude-hook', 'codex-hook'], usage: '{command}', summary: 'Present pending attention at one native agent boundary.', hiddenFromIndex: true },
43
+ { names: ['codex-hook'], usage: '{command}', summary: 'Present pending attention at one native agent boundary.', hiddenFromIndex: true },
44
+ { names: ['claude-mod'], usage: '{command}', summary: 'Receive a structured operation from the loaded Claude mod.', hiddenFromIndex: true },
44
45
  {
45
46
  names: ['history'], usage: 'history [filters] [output]', usesSquare: true, group: 'participant',
46
47
  summary: 'Read or search the archive without changing what you have caught.',
@@ -1,13 +1,11 @@
1
1
  import { type VersionObserver } from './file-changes.js';
2
- import type { HostLedgerPort, PresenceRecord, PresenceKey, PresenceLookup, PresenceResult, PresenceClaimResult, EvidenceRecord, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceGc, ClaimResult, WakeDispatchClaim, WakeDispatchClaimInput, WakeDispatchReleaseInput, WakeDispatchTransitionInput, WakeAttemptLookup } from './host-ledger.js';
2
+ import type { HostLedgerPort, PresenceRecord, PresenceKey, PresenceLookup, PresenceResult, PresenceClaimResult, EvidenceRecord, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceGc, ClaimResult, WakeAttemptClaim, WakeAttemptClaimInput, WakeAttemptLookup } from './host-ledger.js';
3
3
  export interface HostLedgerFileAdapterOptions {
4
4
  rootPath?: string;
5
- claimsPath?: string;
6
5
  now?: () => number;
7
6
  }
8
7
  export declare class FileHostLedgerPort implements HostLedgerPort {
9
8
  private readonly root;
10
- private readonly claims;
11
9
  private readonly clock;
12
10
  constructor(o?: HostLedgerFileAdapterOptions);
13
11
  private file;
@@ -18,20 +16,31 @@ export declare class FileHostLedgerPort implements HostLedgerPort {
18
16
  }>;
19
17
  /** Time can expire bindings/evidence even when their files do not change. */
20
18
  nextExpiry(session: string): Promise<number>;
19
+ /** The ownership claim critical section: the same lock as claimPresence and finalize. */
20
+ withClaimLock<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T>;
21
21
  claimPresence(i: PresenceRecord, signal?: AbortSignal): Promise<PresenceClaimResult>;
22
22
  ensurePresence(i: PresenceRecord): Promise<PresenceResult>;
23
+ /** Local batch suppression is monotonic only for the captured host generation. */
24
+ suppressPresence(i: PresenceRecord, through: number): Promise<boolean>;
23
25
  removePresence(i: PresenceKey): Promise<void>;
24
26
  removePresenceIfUnchanged(i: PresenceRecord): Promise<boolean>;
25
27
  listPresence(i?: PresenceLookup): Promise<readonly PresenceRecord[]>;
26
- claimWakeDispatch(i: WakeDispatchClaimInput): Promise<WakeDispatchClaim>;
27
- transitionWakeDispatch(i: WakeDispatchTransitionInput): Promise<boolean>;
28
- releaseWakeDispatch(i: WakeDispatchReleaseInput): Promise<void>;
28
+ /** One attempt row owns attention-wide exclusion and session-scoped history for a wake. */
29
+ claimWakeAttempt(i: WakeAttemptClaimInput): Promise<WakeAttemptClaim>;
30
+ /** One locked, token-fenced replacement; each operation owns its state condition. */
31
+ private updateWake;
29
32
  listWakeAttempts(i?: WakeAttemptLookup): Promise<readonly EvidenceRecord[]>;
30
- appendWakeAttempt(i: EvidenceRecord & {
33
+ prepareNativeWake(i: EvidenceRecord & {
31
34
  readonly claimToken: string;
32
- }): Promise<void>;
35
+ readonly routeKind: import('./model.js').WakeRouteKind;
36
+ readonly nativeDelivery: import('./host-ledger.js').NativeDeliveryEvidence;
37
+ }): Promise<boolean>;
38
+ confirmWakeAdmission(i: EvidenceRecord & {
39
+ readonly claimToken: string;
40
+ }): Promise<boolean>;
33
41
  claimEvidence(i: EvidenceClaim): Promise<ClaimResult>;
34
42
  releaseEvidence(i: EvidenceRelease): Promise<void>;
43
+ /** Token-fenced terminal write. A token may only finish the attempt it claimed. */
35
44
  appendEvidence(i: EvidenceRecord & {
36
45
  readonly claimToken: string;
37
46
  }): Promise<void>;
@@ -1,12 +1,13 @@
1
1
  import { promises as fs } from 'node:fs';
2
- import fsSync from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { randomUUID } from 'node:crypto';
5
4
  import { setTimeout as sleep } from 'node:timers/promises';
6
5
  import { withFileLock as acquireFileLock } from './file-lock.js';
7
6
  import { nameKey } from './model.js';
7
+ import { formatActivityId } from './square-core.js';
8
8
  import { hostLedgerRoot } from './host-ledger-root.js';
9
9
  import { observeFileMetadata } from './file-changes.js';
10
+ import { canonicalPath, canonicalPathSync } from './canonical-path.js';
10
11
  const LOCK = { retryMs: 10 };
11
12
  const RETENTION = 7 * 86400000;
12
13
  // Prepare before locking: lock databases and their journals are local runtime data too.
@@ -24,32 +25,12 @@ async function withFileLock(file, options, fn) {
24
25
  }
25
26
  return acquireFileLock(file, options, fn);
26
27
  }
27
- async function canon(value) { const absolute = path.resolve(value); try {
28
- return await fs.realpath(absolute);
29
- }
30
- catch {
31
- return absolute;
32
- } }
33
- function canonRoot(value) { let current = path.resolve(value), suffix = []; for (;;) {
34
- try {
35
- return path.join(fsSync.realpathSync.native(current), ...suffix.reverse());
36
- }
37
- catch (error) {
38
- if (error.code !== 'ENOENT')
39
- return path.resolve(value);
40
- const parent = path.dirname(current);
41
- if (parent === current)
42
- return path.resolve(value);
43
- suffix.push(path.basename(current));
44
- current = parent;
45
- }
46
- } }
47
28
  async function read(file, now, includeFuture = false) { try {
48
29
  return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
49
- const row = JSON.parse(line);
30
+ const parsed = JSON.parse(line);
31
+ const row = decodeWakeRecord(parsed);
50
32
  const at = row.at ?? row.updatedAt;
51
- const durableTerminal = row.kind === 'wake' && row.outcome === 'accepted';
52
- return row.v === 1 && typeof at === 'number' && (durableTerminal || at >= now - RETENTION) && (includeFuture || at <= now) ? [row] : [];
33
+ return row.v === 1 && typeof at === 'number' && (durableWake(row) || at >= now - RETENTION) && (includeFuture || at <= now) ? [row] : [];
53
34
  }
54
35
  catch {
55
36
  return [];
@@ -89,21 +70,20 @@ async function write(file, rows, signal) {
89
70
  }
90
71
  }
91
72
  function k(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.channel, v.activity, v.kind]); }
92
- function evidenceKey(v) { return JSON.stringify([v.location, nameKey(v.participant), v.session, v.activity, v.kind, v.attemptN ?? null]); }
93
- async function readClaims(file, now) { try {
94
- return (await fs.readFile(file, 'utf8')).split('\n').flatMap(line => { try {
95
- const row = JSON.parse(line);
96
- return row.v === 1 && typeof row.ts === 'number' && typeof row.attention_key === 'string' && typeof row.leaseId === 'string' && typeof row.expiresAt === 'number' && (row.phase === 'claimed' || row.phase === 'dispatching') && row.ts >= now - RETENTION ? [row] : [];
97
- }
98
- catch {
99
- return [];
100
- } });
73
+ function durableWake(record) {
74
+ const row = record;
75
+ return row.kind === 'wake' && (row.outcome === 'accepted' || row.outcome === 'unknown' || row.outcome === 'dispatching');
76
+ }
77
+ function decodeWakeRecord(row) {
78
+ return row.kind === 'wake' && row.outcome === 'unknown' && row.unknownSource === undefined
79
+ ? { ...row, unknownSource: row.signature === 'worker_interrupted_during_dispatch' ? 'interrupted' : 'transport' } : row;
80
+ }
81
+ /** Keep identity and native receipt, never a completed attempt's lease. */
82
+ function finishWake(current, outcome, details = {}) {
83
+ const { expiresAt: _expiresAt, ownerPid: _ownerPid, unknownSource: _unknownSource, ...rest } = current;
84
+ return { ...rest, ...details, kind: 'wake', outcome,
85
+ ...(outcome === 'unknown' ? { unknownSource: details.unknownSource ?? 'transport' } : {}) };
101
86
  }
102
- catch (e) {
103
- if (e.code === 'ENOENT')
104
- return [];
105
- throw e;
106
- } }
107
87
  function processAlive(pid) { if (pid === undefined)
108
88
  return undefined; try {
109
89
  process.kill(pid, 0);
@@ -112,14 +92,11 @@ function processAlive(pid) { if (pid === undefined)
112
92
  catch (error) {
113
93
  return error.code === 'ESRCH' ? false : true;
114
94
  } }
115
- function writeClaims(file, rows) { return write(file, rows); }
116
95
  export class FileHostLedgerPort {
117
96
  root;
118
- claims;
119
97
  clock;
120
98
  constructor(o = {}) {
121
- this.root = canonRoot(o.rootPath ?? hostLedgerRoot());
122
- this.claims = canonRoot(o.claimsPath ?? path.join(this.root, 'wake-claims.ndjsonl'));
99
+ this.root = canonicalPathSync(o.rootPath ?? hostLedgerRoot());
123
100
  this.clock = o.now ?? Date.now;
124
101
  }
125
102
  file(kind) { return path.join(this.root, `${kind}.ndjsonl`); }
@@ -145,7 +122,7 @@ export class FileHostLedgerPort {
145
122
  if (typeof at === 'number') {
146
123
  if (at > now)
147
124
  next = Math.min(next, at);
148
- if (at + RETENTION + 1 > now && !('kind' in row && row.kind === 'wake' && row.outcome === 'accepted'))
125
+ if (at + RETENTION + 1 > now && !durableWake(row))
149
126
  next = Math.min(next, at + RETENTION + 1);
150
127
  }
151
128
  if ('expiresAt' in row && typeof row.expiresAt === 'number' && row.expiresAt > now)
@@ -153,11 +130,15 @@ export class FileHostLedgerPort {
153
130
  }
154
131
  return next;
155
132
  }
133
+ /** The ownership claim critical section: the same lock as claimPresence and finalize. */
134
+ withClaimLock(fn, signal) {
135
+ return withFileLock(path.join(this.root, 'presence-claim.lock'), { ...LOCK, signal }, fn);
136
+ }
156
137
  async claimPresence(i, signal) {
157
- const location = await canon(i.location);
138
+ const location = await canonicalPath(i.location);
158
139
  const record = { ...i, location, updatedAt: i.updatedAt ?? this.clock(), v: 1 };
159
140
  try {
160
- return await withFileLock(path.join(this.root, 'presence-claim.lock'), { ...LOCK, signal }, async () => {
141
+ return await this.withClaimLock(async () => {
161
142
  const file = this.file('presence');
162
143
  // The authoritative read, ownership check, and write share the presence file lock:
163
144
  // a concurrent ensurePresence/removePresence between them must never be overwritten
@@ -170,28 +151,47 @@ export class FileHostLedgerPort {
170
151
  await write(file, [...rows, record], signal);
171
152
  return { status: 'acquired', record };
172
153
  });
173
- });
154
+ }, signal);
174
155
  }
175
156
  catch (error) {
176
157
  return { status: 'degraded', record, error };
177
158
  }
178
159
  }
179
160
  async ensurePresence(i) {
180
- const record = { ...i, location: await canon(i.location), updatedAt: i.updatedAt ?? this.clock(), v: 1 };
161
+ const record = { ...i, location: await canonicalPath(i.location), updatedAt: i.updatedAt ?? this.clock(), v: 1 };
181
162
  try {
182
- await withFileLock(this.file('presence') + '.lock', LOCK, async () => write(this.file('presence'), [...(await read(this.file('presence'), this.clock(), true)).filter((row) => k(row) !== k(record)), record]));
163
+ await withFileLock(this.file('presence') + '.lock', LOCK, async () => {
164
+ const rows = await read(this.file('presence'), this.clock(), true);
165
+ const current = rows.findLast((row) => k(row) === k(record));
166
+ const sameEpoch = current?.epoch === record.epoch;
167
+ const cancelledThrough = sameEpoch ? Math.max(current?.cancelledThrough ?? -1, record.cancelledThrough ?? -1) : record.cancelledThrough ?? -1;
168
+ await write(this.file('presence'), [...rows.filter((row) => k(row) !== k(record)), { ...record, ...(cancelledThrough < 0 ? {} : { cancelledThrough }) }]);
169
+ });
183
170
  return { status: 'ensured', record };
184
171
  }
185
172
  catch (error) {
186
173
  return { status: 'degraded', record, error };
187
174
  }
188
175
  }
176
+ /** Local batch suppression is monotonic only for the captured host generation. */
177
+ async suppressPresence(i, through) {
178
+ const record = { ...i, location: await canonicalPath(i.location) };
179
+ const file = this.file('presence');
180
+ return withFileLock(file + '.lock', LOCK, async () => {
181
+ const rows = await read(file, this.clock(), true);
182
+ const current = rows.findLast((row) => k(row) === k(record) && row.epoch === record.epoch);
183
+ if (!current)
184
+ return false;
185
+ await write(file, rows.map((row) => row === current ? { ...row, cancelledThrough: Math.max(row.cancelledThrough ?? -1, through) } : row));
186
+ return true;
187
+ });
188
+ }
189
189
  async removePresence(i) {
190
- const record = { ...i, location: await canon(i.location) };
190
+ const record = { ...i, location: await canonicalPath(i.location) };
191
191
  await withFileLock(this.file('presence') + '.lock', LOCK, async () => write(this.file('presence'), (await read(this.file('presence'), this.clock(), true)).filter((row) => k(row) !== k(record))));
192
192
  }
193
193
  async removePresenceIfUnchanged(i) {
194
- const record = { ...i, location: await canon(i.location) };
194
+ const record = { ...i, location: await canonicalPath(i.location) };
195
195
  return withFileLock(this.file('presence') + '.lock', LOCK, async () => {
196
196
  const file = this.file('presence');
197
197
  const rows = await read(file, this.clock(), true);
@@ -206,43 +206,127 @@ export class FileHostLedgerPort {
206
206
  });
207
207
  }
208
208
  async listPresence(i = {}) {
209
- const location = i.location === undefined ? undefined : await canon(i.location);
209
+ const location = i.location === undefined ? undefined : await canonicalPath(i.location);
210
210
  return (await read(this.file('presence'), i.now ?? this.clock())).filter((row) => (!location || row.location === location) && (!i.participant || nameKey(row.participant) === nameKey(i.participant)) && (!i.session || row.session === i.session)).map(({ scope: _scope, ...row }) => row);
211
211
  }
212
- async claimWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); return withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at), existing = rows.find((row) => row.attention_key === key); if (existing?.phase === 'dispatching') {
213
- const alive = processAlive(existing.ownerPid);
214
- if (existing.expiresAt <= at || alive === false)
215
- return { type: 'ambiguous', lease: existing };
216
- return { type: 'busy' };
217
- } if (existing !== undefined && existing.expiresAt > at)
218
- return { type: 'busy' }; const next = { v: 1, ts: at, attention_key: key, leaseId: i.leaseId, expiresAt: at + i.leaseMs, phase: 'claimed', ownerPid: process.pid, ...(i.session === undefined ? {} : { session: i.session }) }; await writeClaims(this.claims, [...rows.filter((row) => row.attention_key !== key), next]); return { type: 'acquired', leaseId: i.leaseId }; }); }
219
- async transitionWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); return withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at), current = rows.find((row) => { if (row.leaseId !== i.leaseId)
220
- return false; try {
221
- const parts = JSON.parse(row.attention_key);
222
- return parts[0] === location && parts[1] === nameKey(i.attention.recipient) && parts[2] === i.attention.actIndex;
212
+ /** One attempt row owns attention-wide exclusion and session-scoped history for a wake. */
213
+ async claimWakeAttempt(i) {
214
+ const location = await canonicalPath(i.attention.squarePath);
215
+ const activity = formatActivityId(i.attention.actIndex);
216
+ const at = i.now ?? this.clock();
217
+ const file = this.file('evidence');
218
+ try {
219
+ return await withFileLock(file + '.lock', LOCK, async () => {
220
+ const all = await read(file, at, true);
221
+ const attention = all.filter((row) => row.kind === 'wake' && row.location === location && nameKey(row.participant) === nameKey(i.attention.recipient) && row.activity === activity);
222
+ const terminal = attention.findLast((row) => row.outcome === 'accepted') ?? attention.findLast((row) => row.outcome === 'unknown');
223
+ if (terminal)
224
+ return { status: 'terminal', record: terminal };
225
+ const interrupted = attention.filter((row) => row.outcome === 'dispatching');
226
+ const busy = interrupted.find((row) => (row.expiresAt ?? 0) > at && processAlive(row.ownerPid) !== false);
227
+ if (busy)
228
+ return { status: 'busy', record: busy };
229
+ const nextNumber = (session) => attention.filter((row) => row.session === session).reduce((highest, row) => Math.max(highest, row.attemptN ?? 0), 0) + 1;
230
+ // An abandoned attempt may already have been sent, so it blocks every route.
231
+ if (interrupted.length) {
232
+ const recovered = interrupted.map((row) => ({ ...finishWake(row, 'unknown', {
233
+ unknownSource: 'interrupted', signature: 'worker_interrupted_during_dispatch',
234
+ message: 'The notification worker ended after dispatch began; transport acceptance is unknown.',
235
+ }), routeKind: row.routeKind ?? i.routeKind, attemptN: row.attemptN ?? nextNumber(row.session), v: 1 }));
236
+ const replacements = new Map(interrupted.map((row, index) => [row, recovered[index]]));
237
+ await write(file, all.map((row) => replacements.get(row) ?? row));
238
+ return { status: 'terminal', record: recovered[0] };
239
+ }
240
+ const attemptN = nextNumber(i.session), claimToken = `${process.pid}-${at}-${randomUUID()}`;
241
+ await write(file, [...all, { v: 1, at, location, participant: i.attention.recipient, session: i.session, activity,
242
+ kind: 'wake', outcome: 'dispatching', routeKind: i.routeKind, attemptN, claimToken, ownerPid: process.pid, expiresAt: at + i.leaseMs }]);
243
+ return { status: 'acquired', claimToken, attemptN };
244
+ });
245
+ }
246
+ catch (error) {
247
+ return { status: 'degraded', error };
248
+ }
223
249
  }
224
- catch {
225
- return false;
226
- } }); if (current === undefined)
227
- return false; const next = { v: 1, ts: at, attention_key: key, leaseId: i.leaseId, expiresAt: at + i.leaseMs, phase: i.phase, ownerPid: current.ownerPid, ...(i.routeKind === undefined ? {} : { routeKind: i.routeKind }), ...(i.attemptN === undefined ? {} : { attemptN: i.attemptN }), ...(i.session === undefined ? {} : { session: i.session }) }; await writeClaims(this.claims, [...rows.filter((row) => row !== current), next]); return true; }); }
228
- async releaseWakeDispatch(i) { const location = await canon(i.attention.squarePath), key = JSON.stringify([location, nameKey(i.attention.recipient), i.attention.actIndex, 'wake']), at = i.at ?? this.clock(); await withFileLock(`${this.claims}.lock`, LOCK, async () => { const rows = await readClaims(this.claims, at); await writeClaims(this.claims, rows.filter((row) => row.attention_key !== key || row.leaseId !== i.leaseId)); }); }
229
- async listWakeAttempts(i = {}) { const attention = i.attention; return this.listEvidence({ kind: 'wake', ...(attention === undefined ? {} : { location: await canon(attention.squarePath), participant: attention.recipient, activity: `act/${attention.actIndex}` }), ...(i.session === undefined ? {} : { session: i.session }), now: i.now }); }
230
- async appendWakeAttempt(i) { if (i.kind !== 'wake')
231
- throw new Error('Wake attempt rows require wake evidence kind'); await this.appendEvidence(i); }
232
- async claimEvidence(i) { const f = this.file('evidence'), at = i.now ?? this.clock(), { leaseMs, claimToken: requestedToken, ...claim } = i, r = { ...claim, location: await canon(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
250
+ /** One locked, token-fenced replacement; each operation owns its state condition. */
251
+ async updateWake(input, update, at = this.clock()) {
252
+ if (!input.claimToken)
253
+ return false;
254
+ const location = await canonicalPath(input.location), file = this.file('evidence');
255
+ return withFileLock(file + '.lock', LOCK, async () => {
256
+ const all = await read(file, at, true);
257
+ const current = all.findLast((row) => row.kind === 'wake' && row.location === location && nameKey(row.participant) === nameKey(input.participant) && row.activity === input.activity && row.session === input.session && row.claimToken === input.claimToken);
258
+ const next = current && update(current);
259
+ if (!next)
260
+ return false;
261
+ if (next !== current)
262
+ await write(file, all.map((row) => row === current ? { ...next, v: 1 } : row));
263
+ return true;
264
+ });
265
+ }
266
+ async listWakeAttempts(i = {}) {
267
+ const attention = i.attention;
268
+ return this.listEvidence({ kind: 'wake', ...(attention === undefined ? {} : { location: await canonicalPath(attention.squarePath), participant: attention.recipient, activity: formatActivityId(attention.actIndex) }), session: i.session, now: i.now });
269
+ }
270
+ async prepareNativeWake(i) {
271
+ return this.updateWake(i, (current) => current.outcome === 'dispatching'
272
+ ? { ...current, nativeDelivery: i.nativeDelivery, routeKind: i.routeKind, attemptN: i.attemptN } : undefined);
273
+ }
274
+ async confirmWakeAdmission(i) {
275
+ return this.updateWake(i, (current) => {
276
+ if (!current.nativeDelivery || !['dispatching', 'unknown', 'accepted'].includes(current.outcome))
277
+ return undefined;
278
+ return current.outcome === 'accepted' ? current : finishWake(current, 'accepted', { signature: 'native_queue_confirmed', message: undefined, diagnostic: undefined });
279
+ });
280
+ }
281
+ async claimEvidence(i) { if (i.kind === 'wake')
282
+ return { status: 'degraded', error: new Error('wake attempts are claimed with claimWakeAttempt') }; const f = this.file('evidence'), at = i.now ?? this.clock(), { leaseMs, ...claim } = i, r = { ...claim, location: await canonicalPath(i.location), outcome: 'dispatching', at, expiresAt: at + leaseMs, v: 1 }; try {
233
283
  return await withFileLock(f + '.lock', LOCK, async () => { const all = await read(f, at, true), matching = all.filter(x => x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session), old = matching.findLast(x => x.outcome === 'dispatching'); if (old !== undefined && typeof old.expiresAt === 'number' && old.expiresAt > at)
234
- return { status: 'busy', record: old }; const delivered = (r.kind === 'wake' && matching.some(x => x.outcome === 'accepted')) || (r.kind === 'presentation' && matching.some(x => x.outcome === 'presented')); if (delivered)
235
- return { status: 'delivered', record: matching.findLast(x => x.outcome === 'accepted' || x.outcome === 'presented') }; const index = Number(r.activity.slice(4)), attentionKey = JSON.stringify([r.location, nameKey(r.participant), index, r.session ?? null, 'wake']), dispatchToken = r.kind === 'wake' && typeof requestedToken === 'string' && Number.isInteger(index) && (await readClaims(this.claims, at)).some((row) => row.leaseId === requestedToken && row.attention_key === attentionKey) ? requestedToken : undefined, claimToken = dispatchToken ?? `${process.pid}-${at}-${Math.random().toString(36).slice(2)}`, row = { ...r, claimToken }; await write(f, [...all.filter(x => !(x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session && (x.outcome === 'dispatching' || x.outcome === 'released'))), row]); return { status: 'acquired', claimToken }; });
284
+ return { status: 'busy', record: old }; if (r.kind === 'presentation' && matching.some(x => x.outcome === 'presented'))
285
+ return { status: 'delivered', record: matching.findLast(x => x.outcome === 'presented') }; const claimToken = `${process.pid}-${at}-${Math.random().toString(36).slice(2)}`, row = { ...r, claimToken }; await write(f, [...all.filter(x => !(x.kind === r.kind && x.location === r.location && nameKey(x.participant) === nameKey(r.participant) && x.activity === r.activity && x.session === r.session && (x.outcome === 'dispatching' || x.outcome === 'released'))), row]); return { status: 'acquired', claimToken }; });
236
286
  }
237
287
  catch (error) {
238
288
  return { status: 'degraded', error };
239
289
  } }
240
- async releaseEvidence(i) { const f = this.file('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)
241
- 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]); }); }
242
- async appendEvidence(i) { const f = this.file('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)
243
- 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)
244
- 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]); }); }
245
- async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canon(i.location); return (await read(this.file('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)); }
246
- async gcEvidence(i) { const f = this.file('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)))); }
290
+ async releaseEvidence(i) {
291
+ if (i.kind === 'wake') {
292
+ const { signature, message, diagnostic, routeKind, attemptN } = i;
293
+ const details = Object.fromEntries(Object.entries({ signature, message, diagnostic, routeKind, attemptN }).filter(([, value]) => value !== undefined));
294
+ await this.updateWake(i, (current) => current.outcome === 'dispatching'
295
+ ? finishWake(current, 'released', { ...details, at: i.now ?? this.clock() }) : undefined, i.now);
296
+ return;
297
+ }
298
+ const f = this.file('evidence'), location = await canonicalPath(i.location), at = i.now ?? this.clock();
299
+ 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)
300
+ 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; delete tombstone.ownerPid; await write(f, [...all.filter(x => x !== current), tombstone]); });
301
+ }
302
+ /** Token-fenced terminal write. A token may only finish the attempt it claimed. */
303
+ async appendEvidence(i) {
304
+ const f = this.file('evidence'), r = { ...i, location: await canonicalPath(i.location), at: i.at ?? this.clock(), v: 1 };
305
+ const token = i.claimToken;
306
+ if (typeof token !== 'string' || token.length === 0)
307
+ return;
308
+ if (i.kind === 'wake') {
309
+ if (r.outcome !== 'accepted' && r.outcome !== 'failed' && r.outcome !== 'unknown')
310
+ return;
311
+ const outcome = r.outcome;
312
+ await this.updateWake(r, (current) => {
313
+ if (current.outcome !== 'dispatching' && !(current.outcome === 'unknown' && current.unknownSource === 'interrupted' && outcome === 'accepted'))
314
+ return undefined;
315
+ return finishWake(current, outcome, { at: r.at, routeKind: r.routeKind, attemptN: r.attemptN, signature: r.signature, message: r.message, diagnostic: r.diagnostic });
316
+ }, r.at);
317
+ return;
318
+ }
319
+ await withFileLock(f + '.lock', LOCK, async () => {
320
+ const all = await read(f, r.at, true);
321
+ 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;
322
+ const dispatching = all.findLast((x) => same(x) && x.outcome === 'dispatching');
323
+ const source = dispatching?.claimToken === token ? dispatching : undefined;
324
+ if (source === undefined)
325
+ return;
326
+ await write(f, [...all.filter((x) => x !== source && !(same(x) && x.attemptN === r.attemptN && x.outcome !== 'dispatching' && x.outcome !== 'released')), { ...r, ...(source.nativeDelivery === undefined ? {} : { nativeDelivery: source.nativeDelivery }) }]);
327
+ });
328
+ }
329
+ async listEvidence(i = {}) { const loc = i.location === undefined ? undefined : await canonicalPath(i.location); return (await read(this.file('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)); }
330
+ async gcEvidence(i) { const f = this.file('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 || durableWake(r) && pending.has(r.activity)))); }
247
331
  }
248
332
  export function createHostLedgerPort(o = {}) { return new FileHostLedgerPort(o); }