@astrosheep/square 0.3.73 → 0.3.75

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 (49) hide show
  1. package/dist/attention-presentation.d.ts +2 -0
  2. package/dist/attention-presentation.js +11 -2
  3. package/dist/boundary-presentation.d.ts +8 -1
  4. package/dist/boundary-presentation.js +8 -1
  5. package/dist/cli/context.d.ts +1 -1
  6. package/dist/cli/express-presentation.js +1 -2
  7. package/dist/cli/ledger-command.js +6 -2
  8. package/dist/cli/meta-commands.js +1 -1
  9. package/dist/decisions.js +6 -21
  10. package/dist/help.js +4 -4
  11. package/dist/host-ledger-migration.d.ts +1 -0
  12. package/dist/host-ledger-migration.js +62 -21
  13. package/dist/host-ledger-sqlite.d.ts +4 -1
  14. package/dist/host-ledger-sqlite.js +36 -9
  15. package/dist/inbox.d.ts +2 -0
  16. package/dist/inbox.js +64 -22
  17. package/dist/mcp.d.ts +2 -1
  18. package/dist/mcp.js +51 -26
  19. package/dist/model.d.ts +2 -3
  20. package/dist/model.js +5 -2
  21. package/dist/open-square.d.ts +12 -2
  22. package/dist/open-square.js +42 -4
  23. package/dist/participant-host.js +6 -2
  24. package/dist/ports.d.ts +10 -0
  25. package/dist/presentation.js +1 -1
  26. package/dist/square-actions.d.ts +3 -1
  27. package/dist/square-actions.js +10 -5
  28. package/dist/square-application.d.ts +4 -0
  29. package/dist/square-application.js +42 -28
  30. package/dist/square-facade.d.ts +26 -22
  31. package/dist/views.d.ts +3 -1
  32. package/dist/views.js +37 -29
  33. package/guides/application-demand-optimization.md +50 -0
  34. package/guides/catch-cold-path-optimization.md +56 -0
  35. package/guides/catch-lock-investigation.md +81 -0
  36. package/guides/host-ledger.md +2 -2
  37. package/guides/mcp-connection-lifetime.md +22 -0
  38. package/guides/pi-boundary-pending-reuse.md +13 -0
  39. package/package.json +2 -1
  40. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  41. package/plugins/claude/skills/square/SKILL.md +1 -1
  42. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  43. package/plugins/paseo/README.md +10 -5
  44. package/plugins/paseo/client/markdown.tsx +221 -0
  45. package/plugins/paseo/client/square-message.tsx +64 -94
  46. package/plugins/paseo/client/transform.ts +4 -1
  47. package/plugins/paseo/shared/message.ts +6 -4
  48. package/plugins/pi/square-pi.js +26 -16
  49. package/skills/square/SKILL.md +1 -1
@@ -12,4 +12,6 @@ export declare function previewAttentionBody(body: string): string;
12
12
  export declare function attentionBodyIsClipped(body: string): boolean;
13
13
  export declare function displayAttentionPath(squarePath: string): string;
14
14
  export declare function renderAttentionDescription({ actor, recipient, route }: Pick<AttentionPreview, 'actor' | 'recipient' | 'route'>): string;
15
+ /** Full notification text for human-facing expansion, never the bounded model preview. */
16
+ export declare function renderAttentionFull(attention: AttentionPreview): string;
15
17
  export declare function renderAttentionPreview(attention: AttentionPreview): string;
@@ -24,14 +24,23 @@ export function renderAttentionDescription({ actor, recipient, route }) {
24
24
  case 'bell': return `${actor} rang the bell · everyone’s attention`;
25
25
  }
26
26
  }
27
- export function renderAttentionPreview(attention) {
27
+ /** Full notification text for human-facing expansion, never the bounded model preview. */
28
+ export function renderAttentionFull(attention) {
29
+ return renderAttentionBlock(attention, attention.body);
30
+ }
31
+ function renderAttentionBlock(attention, body) {
28
32
  return [
29
33
  '<square-activity>',
30
34
  `· ${displayAttentionPath(attention.squarePath)} · ${formatActivityId(attention.actIndex)}`,
31
35
  `● ${renderAttentionDescription(attention)}`,
32
36
  '',
33
- previewAttentionBody(attention.body),
37
+ body,
34
38
  '</square-activity>',
39
+ ].join('\n');
40
+ }
41
+ export function renderAttentionPreview(attention) {
42
+ return [
43
+ renderAttentionBlock(attention, previewAttentionBody(attention.body)),
35
44
  // A clipped body ends with the full command to read it all: a labeled caption, then the
36
45
  // command bare on its own line so it can be copied and run as it is.
37
46
  ...(attentionBodyIsClipped(attention.body)
@@ -1,5 +1,12 @@
1
1
  import type { InboxMembership } from './model.js';
2
2
  export { pendingAtBoundary } from './square-projections.js';
3
3
  export declare function renderPendingAtBoundary(inbox: InboxMembership[]): string;
4
- /** Serialize a boundary across hook processes so one stale inbox cannot render twice. */
4
+ /**
5
+ * Serialize a boundary across hook processes so one stale inbox cannot render twice.
6
+ *
7
+ * `lookup` returns the session's memberships for this boundary. It may reuse an observation it has
8
+ * verified against the current presence binding and artifact revision; the boundary still projects
9
+ * presentation evidence live, and `presentPending` still re-checks the pending head, join, ownership
10
+ * claim and seen CAS. A source that cannot verify its reuse must read the inbox again.
11
+ */
5
12
  export declare function presentPendingAtBoundary<T>(sessionId: string, present: (context: string) => T | Promise<T>, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<T | undefined>;
@@ -140,7 +140,14 @@ async function presentPendingAtBoundaryUnlocked(sessionId, present, lookup = ses
140
140
  }
141
141
  return result;
142
142
  }
143
- /** Serialize a boundary across hook processes so one stale inbox cannot render twice. */
143
+ /**
144
+ * Serialize a boundary across hook processes so one stale inbox cannot render twice.
145
+ *
146
+ * `lookup` returns the session's memberships for this boundary. It may reuse an observation it has
147
+ * verified against the current presence binding and artifact revision; the boundary still projects
148
+ * presentation evidence live, and `presentPending` still re-checks the pending head, join, ownership
149
+ * claim and seen CAS. A source that cannot verify its reuse must read the inbox again.
150
+ */
144
151
  export async function presentPendingAtBoundary(sessionId, present, lookup = sessionInbox, env = process.env, signal) {
145
152
  const ledgerRoot = hostLedgerRoot(env);
146
153
  const ownerKey = createHash('sha256').update(sessionId).digest('hex');
@@ -33,5 +33,5 @@ export interface ParsedGlobalArgs {
33
33
  export declare function parseGlobalArgs(rawArgs: string[]): Promise<ParsedGlobalArgs>;
34
34
  export declare function locationIsRequired(command: string): boolean;
35
35
  export declare function defaultContext(command: string, squarePath?: string, name?: string): CommandContext;
36
- export declare function applicationFor(context: CommandContext): import("../square-facade.js").SquareApplication;
36
+ export declare function applicationFor(context: CommandContext): import("../square-facade.js").SquareApplication<true>;
37
37
  export declare function requireSquarePath(context: CommandContext): string;
@@ -156,8 +156,7 @@ export async function executeExpression(application, squarePath, requestedName,
156
156
  const lines = [message, `· draft kept: ${draftPath}`];
157
157
  const recipientFailure = detail?.subject === 'recipient';
158
158
  const repair = reason === 'bell_mention_conflict' ? { bell: true, fromBody: true }
159
- : reason === 'unmatched_mention' ? { bell: false, fromBody: true }
160
- : reason === 'not_standing' && recipientFailure ? { bell: false, fromBody: false } : undefined;
159
+ : reason === 'not_standing' && recipientFailure ? { bell: false, fromBody: false } : undefined;
161
160
  if (isSquareError(error)) {
162
161
  if ((reason === 'never_joined' && detail?.subject === 'caller') || error.code === 'unknown_participant' || error.code === 'not_joined') {
163
162
  lines.push(joinRecoveryCommand(squarePath, name));
@@ -4,6 +4,7 @@ import { usage } from './context.js';
4
4
  function renderReport(report) {
5
5
  const lines = [
6
6
  `host ledger: ${report.status}`,
7
+ `root: ${report.rootPath}`,
7
8
  `presence: ${report.presence} · evidence: ${report.evidence}`,
8
9
  ];
9
10
  if (report.presenceRevision !== undefined) {
@@ -21,9 +22,12 @@ function renderReport(report) {
21
22
  }
22
23
  if (report.status === 'legacy') {
23
24
  if (report.ready)
24
- lines.push('Stop all Square hosts before conversion.', 'square ledger migrate');
25
+ lines.push('Stop agent sessions and MCP services using Square with this ledger before conversion.', 'Keep them stopped until migration and verification succeed; then start them with the updated Square version.', 'Next: migrate, then verify.', 'square ledger migrate', 'square ledger verify');
25
26
  else
26
- lines.push('Unsafe legacy records; conversion refused.');
27
+ lines.push('Unsafe legacy records; conversion refused. Preserve the sources and resolve the rejected records before retrying verification.');
28
+ }
29
+ if (report.status === 'migrated' || report.status === 'upgraded') {
30
+ lines.push('Keep affected sessions and MCP services stopped until verification succeeds.', 'After verification, start them with the updated Square version.', 'square ledger verify');
27
31
  }
28
32
  return lines.join('\n') + '\n';
29
33
  }
@@ -12,7 +12,7 @@ export const helpCommand = {
12
12
  return renderGlobalHelp();
13
13
  const rendered = renderSubcommandHelp(intent.command);
14
14
  if (rendered === undefined)
15
- fail(`unknown command: ${intent.command}\nrun 'square help' to list available commands`);
15
+ fail(`unknown command: ${intent.command}\nrun 'square --help' to list available commands`);
16
16
  return rendered;
17
17
  },
18
18
  present: (result) => process.stdout.write(result),
package/dist/decisions.js CHANGED
@@ -107,30 +107,15 @@ export function decideAct(facts, input) {
107
107
  throw activityRefusal('invalid_args', `${participantIdentity(requested)} is not standing in this square`, { reason: 'not_standing', targets: [requested] });
108
108
  addMention(resolved);
109
109
  }
110
- if (input.noMention !== true) {
111
- const unmatched = [];
112
- for (const candidate of scanMentionCandidates(body)) {
113
- const resolved = resolveNameAt(body, candidate, joinedNames);
114
- if (resolved === undefined)
115
- unmatched.push(candidate);
116
- else
117
- addMention(resolved);
118
- }
119
- if (reach !== 'bell' && unmatched.length > 0) {
120
- const names = roster.map((participant) => participant.name);
121
- const notStanding = unmatched.filter((candidate) => resolveNameAt(body, candidate, names) !== undefined);
122
- const unknown = unmatched.filter((candidate) => !notStanding.includes(candidate));
123
- const lines = [];
124
- if (unknown.length > 0)
125
- lines.push(`${unknown.map((candidate) => participantIdentity(candidate.token)).join(', ')} ${unknown.length === 1 ? 'does' : 'do'} not match anyone in this square`);
126
- if (notStanding.length > 0)
127
- lines.push(`${notStanding.map((candidate) => participantIdentity(candidate.token)).join(', ')} ${notStanding.length === 1 ? 'is' : 'are'} not standing in this square`);
128
- throw activityRefusal('invalid_args', lines.join('\n'), { reason: 'unmatched_mention', targets: unmatched.map((candidate) => candidate.token) });
129
- }
110
+ const bodyMentions = input.noMention === true ? [] : scanMentionCandidates(body);
111
+ for (const candidate of bodyMentions) {
112
+ const resolved = resolveNameAt(body, candidate, joinedNames);
113
+ if (resolved !== undefined)
114
+ addMention(resolved);
130
115
  }
131
116
  if (reach === 'bell' && mentionNames.length > 0)
132
117
  throw activityRefusal('invalid_args', 'A bell cannot be combined with a mention', { reason: 'bell_mention_conflict', targets: mentionNames });
133
- if (input.requireExplicitReach && reach !== 'bell' && mentionNames.length === 0 && reply === undefined && input.noMention !== true) {
118
+ if (input.requireExplicitReach && reach !== 'bell' && mentionNames.length === 0 && bodyMentions.length === 0 && reply === undefined && input.noMention !== true) {
134
119
  throw activityRefusal('invalid_args', 'Express needs a recipient, reply, bell, or explicit bare reach', { reason: 'explicit_reach_required' });
135
120
  }
136
121
  const header = facts.header();
package/dist/help.js CHANGED
@@ -77,12 +77,12 @@ const COMMANDS = [
77
77
  {
78
78
  names: ['migrate'], usage: 'migrate', usesSquare: true, group: 'maintenance',
79
79
  summary: 'Convert an existing version 1 square to row storage in place.',
80
- details: ['For the selected square artifact only; there is no --check, --backup or --force.', 'A version 2 square is left untouched; an unrelated or unsupported artifact is refused without a write.'],
80
+ details: ['For the selected .square artifact only. Global identity and delivery records use square ledger migrate instead.', 'Stop sessions and services using this artifact before conversion. Back up the stopped artifact, migrate it, then run square --location <path> doctor before starting updated clients.', 'A version 2 square is left untouched; an unrelated or unsupported artifact is refused without a write. There is no --check, --backup or --force.'],
81
81
  },
82
82
  {
83
83
  names: ['ledger'], usage: 'ledger <verify|migrate|gc>', usesSquare: false, group: 'maintenance',
84
84
  summary: 'Verify, convert, or reclaim the machine-local host ledger.',
85
- details: ['verify: read-only SQLite integrity or full legacy readiness with accepted/ignored/rejected counts and source hashes; not-ready exits 2, an empty root stays empty. An older or unsupported database is refused with the migrate command and is never upgraded by verify.', 'migrate: stop every Square host first, then convert legacy NDJSON, or upgrade an older SQLite ledger in place. Sources, records, order and observer revisions are preserved; an existing current ledger is an untouched no-op.', 'gc [--dry-run]: reclaim old wake/presentation evidence only where the square artifact proves the recipient is closed — the activity reached them and catch receipted it or they later rejoined. Dispatching, lifecycle and unprovable rows are retained. Deleted rows free reusable pages, not file size (no VACUUM); retired Claude recognition tokens keep growing in total.', 'Run gc with --dry-run first: it reports eligible, retired, cas-miss and unreadable-artifact counts without writing. Eligible rows are candidates only: a changed record or an unusable Claude correlation is retained.', 'No participant or square location is required. Uses SQUARE_HOST_LEDGER_ROOT, then SQUARE_REGISTRY’s directory, then ~/.square/host-ledger.', 'These commands never stop, restart, install into or schedule hosts; conversion and schema upgrade are never automatic. No --force or repair.'],
85
+ details: ['verify: read-only SQLite integrity or full legacy readiness with accepted/ignored/rejected counts and source hashes; not-ready exits 2, an empty root stays empty. An older supported SQLite format requires explicit migration. Unsupported versions or invalid databases need diagnosis; migration is not a repair command.', 'migrate: stop agent sessions and MCP services using Square with this ledger first, then convert legacy NDJSON or upgrade supported SQLite version 1 in place. Keep them stopped, run square ledger verify afterward, then start them with the updated Square version. Sources, records, order and observer revisions are preserved; an existing current ledger is an untouched no-op.', 'gc [--dry-run]: reclaim old wake/presentation evidence only where the square artifact proves the recipient is closed — the activity reached them and catch receipted it or they later rejoined. Dispatching, lifecycle and unprovable rows are retained. Deleted rows free reusable pages, not file size (no VACUUM); retired Claude recognition tokens keep growing in total.', 'Run gc with --dry-run first: it reports eligible, retired, cas-miss and unreadable-artifact counts without writing. Eligible rows are candidates only: a changed record or an unusable Claude correlation is retained.', 'No participant or square location is required. Uses SQUARE_HOST_LEDGER_ROOT, then SQUARE_REGISTRY’s directory, then ~/.square/host-ledger.', 'These commands never stop, restart, install into or schedule hosts; conversion and schema upgrade are never automatic. No --force or repair.'],
86
86
  },
87
87
  ];
88
88
  function definitionFor(command) {
@@ -131,7 +131,7 @@ export function renderSubcommandHelp(command) {
131
131
  'Help:',
132
132
  ' -h, --help Show this command help.',
133
133
  '',
134
- "Run 'square help' to list available commands.",
134
+ "Run 'square --help' to list available commands.",
135
135
  '',
136
136
  ].join('\n');
137
137
  }
@@ -164,5 +164,5 @@ export function helpRequest(rawArgs) {
164
164
  export function commandUsageHint(command) {
165
165
  return command !== undefined && definitionFor(command) !== undefined
166
166
  ? `Run 'square ${command} --help' for usage.\n`
167
- : "Run 'square help' to list commands.\n";
167
+ : "Run 'square --help' to list commands.\n";
168
168
  }
@@ -7,6 +7,7 @@ export interface LedgerSourceReport {
7
7
  }
8
8
  export interface HostLedgerReport {
9
9
  status: 'empty' | 'legacy' | 'sqlite' | 'migrated' | 'upgraded';
10
+ rootPath: string;
10
11
  presence: number;
11
12
  evidence: number;
12
13
  presenceRevision?: number;
@@ -5,7 +5,7 @@ import { DatabaseSync } from 'node:sqlite';
5
5
  import { canonicalPathSync } from './canonical-path.js';
6
6
  import { withFileLock } from './file-lock.js';
7
7
  import { hostLedgerRoot } from './host-ledger-root.js';
8
- import { LEDGER_FILENAME, initializeLedger, insertLedgerRecord, ledgerRecordColumns, setLedgerRevisions, upgradeLedger, validateLedger, verifyLedger, } from './host-ledger-sqlite.js';
8
+ import { LEDGER_FILENAME, initializeLedger, insertLedgerRecord, ledgerRecordColumns, setLedgerRevisions, upgradeLedger, validateLedger, verifyLedger, inspectLedgerIdentity, } from './host-ledger-sqlite.js';
9
9
  import { inSqliteTransaction } from './sqlite-transaction.js';
10
10
  function rootFor(options) {
11
11
  return canonicalPathSync(options.rootPath ?? hostLedgerRoot(options.env));
@@ -25,21 +25,45 @@ async function exists(file) {
25
25
  throw error;
26
26
  }
27
27
  }
28
- /** Generic and payload-free: the only supported remedy is offline conversion under stopped hosts. */
29
- function verificationFailure() {
30
- return new Error('Host ledger database verification failed; destination left untouched.\nStop all Square hosts, then run: square ledger migrate');
28
+ function migrationGuidance(root, file) {
29
+ const name = file ?? path.join(root, LEDGER_FILENAME);
30
+ return `Host ledger at ${root} has a supported older format (${name}).\nStop agent sessions and MCP services using Square with this ledger; keep them stopped until migration and verification succeed; then start them with the updated Square version.\nsquare ledger migrate\nsquare ledger verify`;
31
31
  }
32
- async function verifyDatabase(file, signal) {
32
+ function verificationFailure(root, file, format) {
33
+ const name = file ?? path.join(root, LEDGER_FILENAME);
34
+ const detail = format === 'unsupported'
35
+ ? 'It has an unsupported format version.'
36
+ : format === 'unrelated'
37
+ ? 'It is unrelated to a Square host ledger.'
38
+ : format === undefined
39
+ ? 'It could not be read.'
40
+ : 'Its identity is current but its contents failed verification.';
41
+ return new Error(`Host ledger verification failed at ${name}; ${detail}\nDestination left untouched. Migration is not a repair command. Keep the affected sessions and MCP services stopped, preserve the ledger, and diagnose the underlying file before retrying.`);
42
+ }
43
+ async function verifyDatabase(file, root, signal) {
33
44
  abort(signal);
34
45
  let db;
35
46
  try {
36
47
  db = new DatabaseSync(file, { readOnly: true });
37
48
  const counts = await inSqliteTransaction(db, false, () => validateLedger(db), () => verifyLedger(db), signal);
38
- return { status: 'sqlite', ...counts };
49
+ return { status: 'sqlite', rootPath: root, ...counts };
39
50
  }
40
51
  catch {
41
52
  abort(signal);
42
- throw verificationFailure();
53
+ let format;
54
+ try {
55
+ db = new DatabaseSync(file, { readOnly: true });
56
+ format = inspectLedgerIdentity(db);
57
+ db.close();
58
+ db = undefined;
59
+ }
60
+ catch {
61
+ db?.close();
62
+ db = undefined;
63
+ }
64
+ if (format === 'old-sqlite')
65
+ throw new Error(migrationGuidance(root, file));
66
+ throw verificationFailure(root, file, format);
43
67
  }
44
68
  finally {
45
69
  db?.close();
@@ -94,9 +118,10 @@ async function readLegacy(root, kind, signal) {
94
118
  async function readSources(root, signal) {
95
119
  return { presence: await readLegacy(root, 'presence', signal), evidence: await readLegacy(root, 'evidence', signal) };
96
120
  }
97
- function sourceReport(sources) {
121
+ function sourceReport(root, sources) {
98
122
  return {
99
123
  status: sources.presence.report.sha256 === null && sources.evidence.report.sha256 === null ? 'empty' : 'legacy',
124
+ rootPath: root,
100
125
  presence: sources.presence.rows.length, evidence: sources.evidence.rows.length,
101
126
  sources: { presence: sources.presence.report, evidence: sources.evidence.report },
102
127
  ready: sources.presence.report.rejected === 0 && sources.evidence.report.rejected === 0,
@@ -108,8 +133,8 @@ export async function verifyHostLedger(options = {}) {
108
133
  abort(options.signal);
109
134
  const destination = path.join(root, LEDGER_FILENAME);
110
135
  if (await exists(destination))
111
- return verifyDatabase(destination, options.signal);
112
- return sourceReport(await readSources(root, options.signal));
136
+ return verifyDatabase(destination, root, options.signal);
137
+ return sourceReport(root, await readSources(root, options.signal));
113
138
  }
114
139
  /** The fixed offline fence: ownership claim, then both legacy source locks, in that order. */
115
140
  function withOfflineLocks(root, signal, body) {
@@ -124,24 +149,24 @@ function withOfflineLocks(root, signal, body) {
124
149
  async function currentFormat(root, signal) {
125
150
  const destination = path.join(root, LEDGER_FILENAME);
126
151
  try {
127
- return await verifyDatabase(destination, signal);
152
+ return await verifyDatabase(destination, root, signal);
128
153
  }
129
154
  catch {
130
155
  abort(signal);
131
156
  }
132
157
  if (!await exists(destination))
133
- throw verificationFailure();
158
+ throw verificationFailure(root);
134
159
  let db;
135
160
  try {
136
161
  db = new DatabaseSync(destination);
137
162
  const upgraded = await inSqliteTransaction(db, true, () => { }, () => upgradeLedger(db), signal);
138
163
  db.close();
139
164
  db = undefined;
140
- return { ...await verifyDatabase(destination, signal), status: upgraded ? 'upgraded' : 'sqlite' };
165
+ return { ...await verifyDatabase(destination, root, signal), status: upgraded ? 'upgraded' : 'sqlite' };
141
166
  }
142
167
  catch {
143
168
  abort(signal);
144
- throw verificationFailure();
169
+ throw verificationFailure(root, destination);
145
170
  }
146
171
  finally {
147
172
  db?.close();
@@ -154,16 +179,32 @@ export async function migrateHostLedger(options = {}) {
154
179
  const destination = path.join(root, LEDGER_FILENAME);
155
180
  if (await exists(destination)) {
156
181
  try {
157
- return await verifyDatabase(destination, signal);
182
+ return await verifyDatabase(destination, root, signal);
158
183
  }
159
184
  catch {
160
185
  abort(signal);
161
186
  }
162
- // An older SQLite format is upgraded offline; a current ledger stays a read-only no-op.
163
- return withOfflineLocks(root, signal, () => currentFormat(root, signal));
187
+ // Inspect only the database identity; record/schema verification remains the migration fence.
188
+ let format;
189
+ let probe;
190
+ try {
191
+ probe = new DatabaseSync(destination, { readOnly: true });
192
+ format = inspectLedgerIdentity(probe);
193
+ }
194
+ catch {
195
+ format = undefined;
196
+ }
197
+ finally {
198
+ probe?.close();
199
+ }
200
+ if (format === 'old-sqlite') {
201
+ // Only the supported prior format enters the existing offline upgrade fence.
202
+ return withOfflineLocks(root, signal, () => currentFormat(root, signal));
203
+ }
204
+ throw verificationFailure(root, destination, format);
164
205
  }
165
206
  if (!await exists(path.join(root, 'presence.ndjsonl')) && !await exists(path.join(root, 'evidence.ndjsonl'))) {
166
- return { status: 'empty', presence: 0, evidence: 0 };
207
+ return { status: 'empty', rootPath: root, presence: 0, evidence: 0 };
167
208
  }
168
209
  return withOfflineLocks(root, signal, () => convertLegacy(root, destination, signal));
169
210
  }
@@ -172,7 +213,7 @@ async function convertLegacy(root, destination, signal) {
172
213
  if (await exists(destination))
173
214
  return currentFormat(root, signal);
174
215
  const sources = await readSources(root, signal);
175
- const report = sourceReport(sources);
216
+ const report = sourceReport(root, sources);
176
217
  if (!report.ready) {
177
218
  const rejected = ['presence', 'evidence'].filter((kind) => sources[kind].report.rejected > 0)
178
219
  .map((kind) => `${kind}: ${sources[kind].report.rejected} rejected (first line ${sources[kind].firstRejectedLine})`);
@@ -198,7 +239,7 @@ async function convertLegacy(root, destination, signal) {
198
239
  }, signal);
199
240
  db.close();
200
241
  db = undefined;
201
- const verified = await verifyDatabase(file, signal);
242
+ const verified = await verifyDatabase(file, root, signal);
202
243
  if (verified.presence !== report.presence || verified.evidence !== report.evidence) {
203
244
  throw new Error('Host ledger import counts do not match; conversion refused.');
204
245
  }
@@ -208,7 +249,7 @@ async function convertLegacy(root, destination, signal) {
208
249
  }
209
250
  catch (error) {
210
251
  if (error.code === 'EEXIST')
211
- return await verifyDatabase(destination, signal);
252
+ return await verifyDatabase(destination, root, signal);
212
253
  throw error;
213
254
  }
214
255
  const directory = await fs.open(root, 'r');
@@ -9,6 +9,7 @@ export interface Stored<T> {
9
9
  readonly seq: number;
10
10
  readonly record: T;
11
11
  }
12
+ export type HostLedgerFormat = 'current' | 'old-sqlite' | 'unrelated' | 'unsupported';
12
13
  /** The complete record remains authority; these are only reproducible lookup projections. */
13
14
  export declare function ledgerRecordColumns(kind: Kind, input: object): Record<string, SQLInputValue>;
14
15
  export declare function initializeLedger(db: DatabaseSync): void;
@@ -28,9 +29,11 @@ export declare function verifyLedger(db: DatabaseSync): {
28
29
  presenceRevision: number;
29
30
  evidenceRevision: number;
30
31
  };
32
+ /** Read the file-type stamp and format version without making the runtime authority. */
33
+ export declare function inspectLedgerIdentity(db: DatabaseSync): HostLedgerFormat;
31
34
  /** Explicit offline migration only; the caller owns the write transaction. Preserve all records and revisions. */
32
35
  export declare function upgradeLedger(db: DatabaseSync): boolean;
33
- export declare function migrationRequired(): Error;
36
+ export declare function migrationRequired(root: string): Error;
34
37
  /** Transaction-local storage rows; sequence coordinates never cross HostLedgerPort. */
35
38
  export declare class LedgerTransaction {
36
39
  private readonly db;
@@ -137,6 +137,21 @@ function verifyLedgerAtVersion(db, version) {
137
137
  throw invalid('records without revision');
138
138
  return { ...counts, presenceRevision: revision.presence, evidenceRevision: revision.evidence };
139
139
  }
140
+ function inspectLegacyRoot(root) {
141
+ return fs.existsSync(path.join(root, 'presence.ndjsonl')) || fs.existsSync(path.join(root, 'evidence.ndjsonl'));
142
+ }
143
+ /** Read the file-type stamp and format version without making the runtime authority. */
144
+ export function inspectLedgerIdentity(db) {
145
+ const application = db.prepare('PRAGMA application_id').get().application_id;
146
+ const version = db.prepare('PRAGMA user_version').get().user_version;
147
+ if (application !== LEDGER_APPLICATION_ID)
148
+ return 'unrelated';
149
+ if (version === LEDGER_VERSION)
150
+ return 'current';
151
+ if (version === 1)
152
+ return 'old-sqlite';
153
+ return 'unsupported';
154
+ }
140
155
  /** Explicit offline migration only; the caller owns the write transaction. Preserve all records and revisions. */
141
156
  export function upgradeLedger(db) {
142
157
  if (db.prepare('PRAGMA user_version').get()?.user_version === LEDGER_VERSION) {
@@ -159,14 +174,26 @@ catch (error) {
159
174
  return false;
160
175
  throw error;
161
176
  } }
162
- export function migrationRequired() {
163
- return new Error('Host ledger conversion required. Stop all ledger users before running:\nsquare ledger migrate');
177
+ export function migrationRequired(root) {
178
+ return new Error(`Host ledger legacy format needs offline migration at ${root}.\nStop agent sessions and MCP services using Square with this ledger; keep them stopped until migration and verification succeed; then start them with the updated Square version.\nRun square ledger migrate from the same environment, then square ledger verify.`);
164
179
  }
165
180
  /** Compact runtime framing. Full schema, projections and integrity belong to explicit verify. */
166
181
  function validateLedgerHeader(db, version = LEDGER_VERSION) {
167
- if (db.prepare('PRAGMA application_id').get()?.application_id !== LEDGER_APPLICATION_ID
168
- || db.prepare('PRAGMA user_version').get()?.user_version !== version)
169
- throw invalid('unsupported identity or version. Stop all ledger users before running:\nsquare ledger migrate');
182
+ if (db.prepare('PRAGMA application_id').get()?.application_id !== LEDGER_APPLICATION_ID)
183
+ throw invalid('unrelated identity.');
184
+ if (db.prepare('PRAGMA user_version').get()?.user_version !== version)
185
+ throw invalid('unsupported format version.');
186
+ }
187
+ /** The runtime's only upgrade door: a supported old ledger is pointed at explicit migration. */
188
+ function runtimeLedgerHeader(db, root) {
189
+ try {
190
+ validateLedgerHeader(db);
191
+ }
192
+ catch (error) {
193
+ if (inspectLedgerIdentity(db) === 'old-sqlite')
194
+ throw migrationRequired(root);
195
+ throw error;
196
+ }
170
197
  }
171
198
  /** Publish new empty authority only. Never initialize or replace an existing destination. */
172
199
  function createEmptyLedger(root) {
@@ -203,8 +230,8 @@ function createEmptyLedger(root) {
203
230
  async function openLedger(root, write, signal) {
204
231
  const file = path.join(root, LEDGER_FILENAME);
205
232
  if (!exists(file)) {
206
- if (exists(path.join(root, 'presence.ndjsonl')) || exists(path.join(root, 'evidence.ndjsonl')))
207
- throw migrationRequired();
233
+ if (inspectLegacyRoot(root))
234
+ throw migrationRequired(root);
208
235
  if (!write)
209
236
  return undefined;
210
237
  if (signal?.aborted)
@@ -318,7 +345,7 @@ export async function withLedger(root, write, select, absent, signal) {
318
345
  if (!db)
319
346
  return absent;
320
347
  try {
321
- return await inSqliteTransaction(db, write, () => validateLedgerHeader(db), () => {
348
+ return await inSqliteTransaction(db, write, () => runtimeLedgerHeader(db, root), () => {
322
349
  const rows = new LedgerTransaction(db), result = select(rows);
323
350
  rows.commitRevisions();
324
351
  return result;
@@ -333,7 +360,7 @@ export async function readLedgerRevisions(root, signal) {
333
360
  if (!db)
334
361
  return undefined;
335
362
  try {
336
- return await inSqliteTransaction(db, false, () => validateLedgerHeader(db), () => ledgerRevisions(db), signal);
363
+ return await inSqliteTransaction(db, false, () => runtimeLedgerHeader(db, root), () => ledgerRevisions(db), signal);
337
364
  }
338
365
  finally {
339
366
  db.close();
package/dist/inbox.d.ts CHANGED
@@ -36,6 +36,8 @@ export type NativePendingPreviewInput = {
36
36
  export declare function nativePendingPreview(facts: SquareRead, input: NativePendingPreviewInput): NativePendingPreview | undefined;
37
37
  export declare function sessionInbox(sessionId: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<InboxMembership[]>;
38
38
  export interface SessionPendingObserver {
39
+ /** Read committed revisions before reusing content; filesystem hints are only for waiting. */
40
+ read(options?: Pick<PendingWaitOptions, 'signal' | 'excludeKeys'>): Promise<InboxMembership[]>;
39
41
  wait(timeoutMs: number, options?: PendingWaitOptions): Promise<InboxMembership[]>;
40
42
  close(): void;
41
43
  }
package/dist/inbox.js CHANGED
@@ -109,7 +109,66 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
109
109
  const ledger = await hostLedger.observeChanges();
110
110
  const artifacts = new Map();
111
111
  const lifetime = new AbortController();
112
+ const bindingsNow = async () => Promise.all((await projectSessionBindings({ hostLedger, sessionId })).map(async (binding) => ({ ...binding, location: await canonicalPath(binding.location) })));
113
+ async function membershipAt(binding, version, signal) {
114
+ const cache = artifacts.get(binding.location)?.inbox;
115
+ const name = nameKey(binding.participant);
116
+ const coordinate = bindingCoordinate(binding);
117
+ let cached = cache?.get(name);
118
+ if (cached === undefined || cached.version !== version || cached.binding !== coordinate) {
119
+ cached = { ...await readInboxMembership(binding, signal), binding: coordinate };
120
+ cache?.set(name, cached);
121
+ }
122
+ const membership = cached.value;
123
+ if (membership === undefined)
124
+ return undefined;
125
+ const { catchLease, ...rest } = membership;
126
+ const now = Date.now();
127
+ return catchLease !== undefined && catchLease.expiresAt > now && now - catchLease.heartbeatAt <= WATCH_STALE_MS
128
+ ? membership : rest;
129
+ }
130
+ async function eligibleInbox(inbox, excludeKeys) {
131
+ const eligible = await projectBoundaryEligibility({ hostLedger, sessionId, inbox });
132
+ return excludeKeys === undefined ? eligible : eligible.flatMap((membership) => {
133
+ const notifications = membership.notifications.filter((notification) => !excludeKeys.has(notificationKey(membership, notification.actIndex)));
134
+ return notifications.length === 0 ? [] : [{ ...membership, notifications }];
135
+ });
136
+ }
112
137
  return {
138
+ async read(options = {}) {
139
+ const signal = AbortSignal.any([lifetime.signal, ...(options.signal === undefined ? [] : [options.signal])]);
140
+ signal.throwIfAborted();
141
+ const bindings = await bindingsNow();
142
+ const revisions = new Map();
143
+ const inbox = [];
144
+ for (const binding of bindings) {
145
+ signal.throwIfAborted();
146
+ try {
147
+ let version = revisions.get(binding.location);
148
+ if (version === undefined) {
149
+ const artifact = createArtifactPort(binding.location);
150
+ try {
151
+ version = await artifact.revision(signal);
152
+ }
153
+ finally {
154
+ await artifact.close();
155
+ }
156
+ revisions.set(binding.location, version);
157
+ }
158
+ const membership = await membershipAt(binding, version, signal);
159
+ if (membership !== undefined)
160
+ inbox.push(membership);
161
+ }
162
+ catch (error) {
163
+ if (signal.aborted)
164
+ throw signal.reason ?? error;
165
+ // An unreadable current revision must never authorize cached content.
166
+ }
167
+ }
168
+ const pending = await eligibleInbox(inbox, options.excludeKeys);
169
+ signal.throwIfAborted();
170
+ return pending;
171
+ },
113
172
  async wait(timeoutMs, options = {}) {
114
173
  const deadline = Date.now() + Math.max(0, timeoutMs);
115
174
  const expired = new AbortController();
@@ -123,7 +182,7 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
123
182
  while (!signal.aborted) {
124
183
  // Subscribe/capture the ledger BEFORE discovering bindings; a concurrent join cannot fall in a gap.
125
184
  const sources = await Promise.all([capture(ledger.presence, 'presence', signal), capture(ledger.evidence, 'evidence', signal)]);
126
- const bindings = await Promise.all((await projectSessionBindings({ hostLedger, sessionId })).map(async (binding) => ({ ...binding, location: await canonicalPath(binding.location) })));
185
+ const bindings = await bindingsNow();
127
186
  const paths = new Set(bindings.map((binding) => binding.location));
128
187
  for (const [file, entry] of artifacts) {
129
188
  if (!paths.has(file)) {
@@ -155,22 +214,10 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
155
214
  for (const binding of bindings) {
156
215
  if (signal.aborted)
157
216
  return [];
158
- const entry = artifacts.get(binding.location);
159
- const name = nameKey(binding.participant);
160
217
  try {
161
- const coordinate = bindingCoordinate(binding);
162
- const cached = entry.inbox.get(name);
163
- if (cached?.version !== revisions.get(binding.location) || cached?.binding !== coordinate) {
164
- const projection = await readInboxMembership(binding, signal);
165
- entry.inbox.set(name, { ...projection, binding: coordinate });
166
- }
167
- const membership = entry.inbox.get(name)?.value;
168
- if (membership === undefined)
169
- continue;
170
- const { catchLease, ...rest } = membership;
171
- const now = Date.now();
172
- inbox.push(catchLease !== undefined && catchLease.expiresAt > now && now - catchLease.heartbeatAt <= WATCH_STALE_MS
173
- ? membership : rest);
218
+ const membership = await membershipAt(binding, revisions.get(binding.location), signal);
219
+ if (membership !== undefined)
220
+ inbox.push(membership);
174
221
  }
175
222
  catch (error) {
176
223
  if (signal.aborted)
@@ -178,12 +225,7 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
178
225
  // Unreadable projections are retried, never cached as current.
179
226
  }
180
227
  }
181
- const eligible = await projectBoundaryEligibility({ hostLedger, sessionId, inbox });
182
- const excludeKeys = options.excludeKeys;
183
- const pending = excludeKeys === undefined ? eligible : eligible.flatMap((membership) => {
184
- const notifications = membership.notifications.filter((notification) => !excludeKeys.has(notificationKey(membership, notification.actIndex)));
185
- return notifications.length === 0 ? [] : [{ ...membership, notifications }];
186
- });
228
+ const pending = await eligibleInbox(inbox, options.excludeKeys);
187
229
  const eligibleKeys = JSON.stringify(pending.flatMap((membership) => membership.notifications.map((note) => notificationKey(membership, note.actIndex))).sort());
188
230
  if (previousEligible !== undefined && previousEligible !== eligibleKeys)
189
231
  canReturn = true;
package/dist/mcp.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { SquareApplicationContext } from './square-facade.js';
3
- export interface SquareMcpOptions extends SquareApplicationContext {
3
+ /** The server owns its artifact connection lifetime, so callers cannot lend one. */
4
+ export interface SquareMcpOptions extends Omit<SquareApplicationContext, 'resources'> {
4
5
  }
5
6
  export type SquareMcpServer = McpServer;
6
7
  export declare function createSquareMcpServer(options: SquareMcpOptions): SquareMcpServer;