@astrosheep/square 0.3.61 → 0.3.62

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 (87) hide show
  1. package/claude-plugin/.claude-plugin/plugin.json +1 -1
  2. package/claude-plugin/skills/square/SKILL.md +5 -7
  3. package/codex-plugin/.codex-plugin/plugin.json +1 -1
  4. package/dist/activity.d.ts +6 -2
  5. package/dist/activity.js +168 -133
  6. package/dist/artifact.d.ts +4 -0
  7. package/dist/artifact.js +43 -14
  8. package/dist/attention-presentation.d.ts +1 -0
  9. package/dist/attention-presentation.js +8 -6
  10. package/dist/automatic-session.js +14 -24
  11. package/dist/boundary-presentation.d.ts +1 -2
  12. package/dist/boundary-presentation.js +4 -19
  13. package/dist/cli/context.d.ts +2 -0
  14. package/dist/cli/context.js +2 -18
  15. package/dist/cli/harness-command.js +1 -1
  16. package/dist/cli/mcp-server-command.d.ts +2 -0
  17. package/dist/cli/mcp-server-command.js +13 -0
  18. package/dist/cli/observation-commands.js +47 -19
  19. package/dist/cli/program.js +5 -2
  20. package/dist/cli/registry.js +2 -0
  21. package/dist/cli/square-commands.d.ts +1 -1
  22. package/dist/cli/square-commands.js +93 -125
  23. package/dist/decisions.d.ts +2 -0
  24. package/dist/decisions.js +44 -3
  25. package/dist/delivery-operations.d.ts +1 -2
  26. package/dist/delivery-operations.js +1 -4
  27. package/dist/file-changes.d.ts +12 -0
  28. package/dist/file-changes.js +235 -0
  29. package/dist/help.js +4 -8
  30. package/dist/host-ledger-file-adapter.d.ts +15 -13
  31. package/dist/host-ledger-file-adapter.js +131 -79
  32. package/dist/host-ledger-root.d.ts +2 -0
  33. package/dist/host-ledger-root.js +9 -0
  34. package/dist/host-ledger.d.ts +4 -24
  35. package/dist/inbox.d.ts +10 -5
  36. package/dist/inbox.js +152 -67
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.js +1 -0
  39. package/dist/mcp.d.ts +7 -0
  40. package/dist/mcp.js +122 -0
  41. package/dist/mention-parse.d.ts +8 -0
  42. package/dist/mention-parse.js +107 -0
  43. package/dist/model.d.ts +5 -0
  44. package/dist/model.js +4 -1
  45. package/dist/notifications.d.ts +1 -0
  46. package/dist/notifications.js +25 -20
  47. package/dist/participant-host.js +2 -2
  48. package/dist/ports.d.ts +2 -9
  49. package/dist/presentation.d.ts +12 -1
  50. package/dist/presentation.js +42 -12
  51. package/dist/presented.js +2 -1
  52. package/dist/registry.d.ts +8 -4
  53. package/dist/registry.js +41 -74
  54. package/dist/routes.d.ts +19 -2
  55. package/dist/routes.js +48 -5
  56. package/dist/runtime.d.ts +2 -0
  57. package/dist/runtime.js +2 -0
  58. package/dist/square-actions.d.ts +26 -13
  59. package/dist/square-actions.js +200 -84
  60. package/dist/square-application.d.ts +58 -0
  61. package/dist/square-application.js +183 -0
  62. package/dist/square-facade.d.ts +24 -9
  63. package/dist/square-file-adapter.d.ts +5 -12
  64. package/dist/square-file-adapter.js +12 -64
  65. package/dist/square-projections.d.ts +3 -2
  66. package/dist/square-projections.js +12 -3
  67. package/dist/square-storage.js +27 -34
  68. package/dist/square-wiring.d.ts +12 -7
  69. package/dist/square-wiring.js +36 -32
  70. package/dist/state-cell.d.ts +1 -1
  71. package/dist/stream.js +30 -26
  72. package/dist/views.d.ts +3 -3
  73. package/dist/views.js +4 -4
  74. package/dist/wake-attempts.js +2 -1
  75. package/dist/wake-evidence.js +2 -8
  76. package/dist/wakes.d.ts +1 -0
  77. package/dist/wakes.js +5 -5
  78. package/dist/watch.d.ts +6 -1
  79. package/dist/watch.js +20 -18
  80. package/extensions/square-pi.js +100 -112
  81. package/guides/application-boundary.md +32 -0
  82. package/guides/change-waits.md +28 -0
  83. package/guides/host-ledger.md +9 -0
  84. package/guides/mcp.md +9 -0
  85. package/guides/operation-cancellation.md +17 -0
  86. package/package.json +8 -2
  87. package/skills/square/SKILL.md +5 -7
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.61",
3
+ "version": "0.3.62",
4
4
  "description": "Native Claude Code turn-boundary delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -63,9 +63,9 @@ The ownership boundary belongs here. Does this match your read?
63
63
  EOF
64
64
  ```
65
65
 
66
- **Addressing.** Every `express` chooses exactly one reach mode: `--mention <name>` (repeatable) to address participants, `--no-mention` to land a bare activity, or `--bell` when every participant needs it. Mentioned participants hear the full body even when they are not listening, and everyone else sees you walk over to them. `listen` opts a participant into future bare delivery. An `@name` in the body is ordinary Markdown and does not address anyone. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
66
+ **Addressing.** Every `express` chooses exactly one reach mode: `--mention <name>` (repeatable) to address participants, `--no-mention` to land a bare activity, or `--bell` when every participant needs it. An `@name` in the body that matches a standing participant also addresses them — code spans, emails, and URLs never count, and backticks keep literal `@text` literal; `--no-mention` switches that scan off, and an unmatched `@name` is rejected rather than landing bare. Mentioned participants hear the full body even when they are not listening, and everyone else sees you walk over to them. `listen` opts a participant into future bare delivery. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
67
67
 
68
- Every human-CLI `express` must choose exactly one reach mode: one or more `--mention <name>` flags, `--no-mention`, or `--bell`. The CLI rejects an omitted mode and rejects combining these modes. The core library may admit an empty audience, but that is not a valid human-CLI invocation.
68
+ Every human-CLI `express` must choose exactly one reach mode: one or more `--mention <name>` flags, `--no-mention`, `--bell`, or an `@name` in the body. The CLI rejects an omitted mode and rejects combining these flag modes. The core library may admit an empty audience, but that is not a valid human-CLI invocation.
69
69
 
70
70
  **Discipline.** Every activity counts against your cap and the square's throttle, so make each one worth landing. Keep private progress and tool chatter out — express only when another participant needs the thought, question, or decision.
71
71
 
@@ -128,13 +128,11 @@ square --location <square> --as <name> resume
128
128
  `done` is permanent, not the end of a conversation round. Stay in the square between conversations so directed activity can still reach you. Use `done` only after you no longer want to participate in or receive anything from this square — the whole square sees you go:
129
129
 
130
130
  ```bash
131
- square --location <square> --as <name> done - <<'EOF'
132
- *pushes the chair back*
133
-
134
- Final state, decision, or handoff.
135
- EOF
131
+ square --location <square> --as <name> done
136
132
  ```
137
133
 
134
+ `done` only leaves the square and takes no message. Send any activity with `express` before leaving.
135
+
138
136
  ## Environment
139
137
 
140
138
  - `SQUARE_LOCATION` sets the square location.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.61",
3
+ "version": "0.3.62",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -1,10 +1,14 @@
1
1
  export interface ActivityOptions {
2
+ cwd?: string;
3
+ env?: NodeJS.ProcessEnv;
2
4
  force?: boolean;
3
- forceCommand: string;
4
5
  noWait?: boolean;
6
+ noMention?: boolean;
5
7
  mentions?: readonly string[];
6
8
  reach?: import('./model.js').Reach;
7
- reply?: number;
9
+ reply?: string;
8
10
  }
9
11
  export declare function saveActivityDraft(squarePath: string, name: string, body: string): string;
12
+ /** Retry commands preserve the original reach, reply, wait and force choices. */
13
+ export declare function activityRetryCommand(squarePath: string, name: string, opts: ActivityOptions, force?: boolean): string;
10
14
  export declare function cmdActivity(squarePath: string, name: string, activity: string, resolveBody: (arg: string) => string, opts: ActivityOptions): Promise<void>;
package/dist/activity.js CHANGED
@@ -1,19 +1,18 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { setTimeout as sleep } from 'node:timers/promises';
5
4
  import { SquareError, isSquareError, validateName } from './model.js';
6
- import { expressHintLine, formatRefusal, renderActivityBlocked, renderActivityLimit, renderExpressNoWait, renderExpressWaiting, renderPendingFeed, joinRecoveryCommand, withPathOutput, } from './presentation.js';
7
- import { nowMs, inSquareCount, SLEEP_MS } from './runtime.js';
5
+ import { expressHintLine, formatRefusal, renderActivityBlocked, renderActivityLimit, renderActivityUncertain, renderExpressNoWait, renderExpressWaiting, renderPendingFeed, joinRecoveryCommand, participantIdentity, participantsRecoveryCommand, participantCommandPrefix, commandPrefix, quoteShell, takeoverRecoveryLines, withPathOutput, } from './presentation.js';
6
+ import { nowMs, SLEEP_MS } from './runtime.js';
8
7
  import { unreadActivitySummaries } from './decisions.js';
9
8
  import { openSquare } from './square-file-adapter.js';
10
9
  import { closeOpenSquare } from './open-square.js';
11
- import { Square } from './square-wiring.js';
10
+ import { createSquareApplication } from './square-application.js';
12
11
  import { activityPresentation, resolveParticipant } from './views.js';
13
- import { formatActivityId } from './square-core.js';
12
+ import { formatActivityId, parseActivityId } from './square-core.js';
14
13
  import { formatDuration } from './time.js';
15
14
  import { style } from './tty-style.js';
16
- import { createHostLedgerPort } from './host-ledger-file-adapter.js';
15
+ import { hostLedgerForEnv } from './registry.js';
17
16
  import { createDefaultWakeTransport } from './notifications.js';
18
17
  function draftDirFor(squarePath) {
19
18
  return path.join(path.dirname(squarePath), 'drafts');
@@ -42,140 +41,176 @@ export function saveActivityDraft(squarePath, name, body) {
42
41
  }
43
42
  }
44
43
  }
44
+ /** Retry commands preserve the original reach, reply, wait and force choices. */
45
+ export function activityRetryCommand(squarePath, name, opts, force = opts.force ?? false) {
46
+ const reach = opts.reach === 'bell' ? ' --bell'
47
+ : opts.mentions?.length ? opts.mentions.map((target) => ` --mention ${quoteShell(target)}`).join('')
48
+ : opts.noMention === true ? ' --no-mention'
49
+ : '';
50
+ const replyIndex = opts.reply === undefined ? undefined : parseActivityId(opts.reply);
51
+ const reply = opts.reply === undefined ? '' : ` --reply ${replyIndex === undefined ? quoteShell(opts.reply) : formatActivityId(replyIndex)}`;
52
+ return `${participantCommandPrefix(squarePath, name)} express${force ? ' --force' : ''}${opts.noWait ? ' --no-wait' : ''}${reach}${reply} -`;
53
+ }
45
54
  export async function cmdActivity(squarePath, name, activity, resolveBody, opts) {
46
- validateName(name);
47
- const rawInput = String(resolveBody(activity)).replace(/\r\n/g, '\n');
48
- const reader = await openSquare(squarePath, { clock: nowMs });
49
- let knownName;
50
- try {
51
- knownName = (await resolveParticipant(reader, name)).name;
52
- }
53
- catch (err) {
54
- const participantCount = inSquareCount((await reader.artifact.read()).state);
55
- await closeOpenSquare(reader);
56
- if (isSquareError(err)) {
57
- if (err.code === 'unknown_participant' || err.code === 'invalid_args') {
58
- const draftPath = saveActivityDraft(squarePath, name, rawInput);
59
- const bodyLines = [err.message, `· draft kept: ${draftPath}`];
60
- if (/^✕ .* has never stepped into/.test(err.message))
61
- bodyLines.push(joinRecoveryCommand(squarePath, name));
62
- process.stderr.write(formatRefusal(squarePath, bodyLines, { participantCount }));
63
- process.exit(2);
64
- }
65
- process.stderr.write(err.message + '\n');
66
- process.exit(err.code === 'not_found' ? 1 : 2);
55
+ const rawInput = String(resolveBody(activity));
56
+ const env = { ...(opts.env ?? process.env) };
57
+ const hostLedger = hostLedgerForEnv(env);
58
+ const retryCommand = activityRetryCommand(squarePath, name, opts);
59
+ const forceCommand = activityRetryCommand(squarePath, name, opts, true);
60
+ let knownName = name;
61
+ let callerMissing = false;
62
+ let expressAttempted = false;
63
+ let landedId;
64
+ let announcedWait;
65
+ async function presentation() {
66
+ const square = await openSquare(squarePath, { clock: nowMs, env, hostLedger });
67
+ try {
68
+ return await activityPresentation(square, knownName);
69
+ }
70
+ finally {
71
+ await closeOpenSquare(square);
67
72
  }
68
- throw err;
69
73
  }
70
- await closeOpenSquare(reader);
71
- const body = rawInput.trim();
72
- const force = opts.force ?? false;
73
- const noWait = opts.noWait ?? false;
74
- const reach = opts.reach === 'bell' ? 'bell' : undefined;
75
- let announcedWait;
76
- const ledgerRoot = process.env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(process.env.SQUARE_REGISTRY);
77
- const hostLedger = createHostLedgerPort({ userPath: process.env.SQUARE_HOST_LEDGER_USER ?? ledgerRoot, localPath: process.env.SQUARE_HOST_LEDGER_LOCAL ?? ledgerRoot });
78
- const square = await Square.at({ path: squarePath, clock: nowMs, hostLedger, wakeTransport: await createDefaultWakeTransport(hostLedger, nowMs) });
79
74
  try {
80
- const participant = await square.join(name);
81
- while (true) {
82
- const beforeSquare = await openSquare(squarePath, { clock: nowMs });
83
- const before = await activityPresentation(beforeSquare, knownName).finally(() => closeOpenSquare(beforeSquare));
84
- const pendingPublic = before.pendingPublic;
85
- const pendingRoomChanges = before.pendingRoomChanges;
86
- try {
87
- const landed = await participant.express(body, {
88
- force,
89
- ...(opts.mentions === undefined ? {} : { mentions: opts.mentions }),
90
- ...(reach === undefined ? {} : { reach }),
91
- ...(opts.reply === undefined ? {} : { reply: formatActivityId(opts.reply) }),
92
- });
93
- const freshSquare = await openSquare(squarePath, { clock: nowMs });
94
- const fresh = await activityPresentation(freshSquare, knownName).finally(() => closeOpenSquare(freshSquare));
95
- const headerCount = fresh.participantCount;
96
- const held = fresh.held;
97
- const ownActCount = fresh.ownActivityCount;
98
- const hasPending = pendingPublic.length > 0 || pendingRoomChanges.length > 0;
99
- const pending = hasPending ? `\n\n${renderPendingFeed([...fresh.activities], [...pendingPublic], [...pendingRoomChanges], knownName, fresh.state)}` : '';
100
- const hint = expressHintLine(ownActCount);
101
- const confirmation = `● your activity lands${style('dim', ` — #${ownActCount} · ${landed.activity.id}`)}`;
102
- const withHint = hint ? `${confirmation}\n${style('dim', hint)}` : confirmation;
103
- process.stdout.write(withPathOutput(squarePath, withHint + pending, { participantCount: headerCount, held }));
75
+ validateName(name);
76
+ for (const target of opts.mentions ?? [])
77
+ validateName(target);
78
+ if (opts.reply !== undefined && parseActivityId(opts.reply) === undefined) {
79
+ throw new SquareError('invalid_args', 'Invalid --reply: expected an activity id like act/12');
80
+ }
81
+ const reader = await openSquare(squarePath, { clock: nowMs, env, hostLedger });
82
+ try {
83
+ knownName = (await resolveParticipant(reader, name)).name;
84
+ }
85
+ catch (error) {
86
+ // The caller name was validated above; invalid_args here means it is absent
87
+ // from the roster. Keep the join recovery distinct from recipient validation.
88
+ callerMissing = isSquareError(error) && error.code === 'invalid_args';
89
+ throw error;
90
+ }
91
+ finally {
92
+ await closeOpenSquare(reader);
93
+ }
94
+ const application = createSquareApplication({ cwd: opts.cwd ?? process.cwd(), env, squarePath, clock: nowMs, hostLedger, wakeTransport: await createDefaultWakeTransport(hostLedger, nowMs), participant: name });
95
+ // The caller is roster-known at this point, so a landed join act is always a
96
+ // re-entry after done — never a first arrival. Announce it; a silent return
97
+ // would make `done` a fake exit.
98
+ const entry = await application.join();
99
+ const reentered = entry.kind === 'joined' && entry.activity !== null;
100
+ const before = await presentation();
101
+ const pendingPublic = before.pendingPublic;
102
+ const pendingRoomChanges = before.pendingRoomChanges;
103
+ expressAttempted = true;
104
+ const landed = await application.express(rawInput.replace(/\r\n/g, '\n').trim(), {
105
+ force: opts.force ?? false,
106
+ noWait: opts.noWait ?? false,
107
+ ...(opts.mentions === undefined ? {} : { mentions: opts.mentions }),
108
+ ...(opts.noMention === undefined ? {} : { noMention: opts.noMention }),
109
+ ...(opts.reach === undefined ? {} : { reach: opts.reach }),
110
+ // The action boundary validates the raw id before attempting a commit.
111
+ ...(opts.reply === undefined ? {} : { reply: opts.reply }),
112
+ }, {
113
+ onProgress: (progress) => {
114
+ if (announcedWait === progress.reason)
115
+ return;
116
+ const delayMs = progress.delayMs ?? SLEEP_MS;
117
+ process.stdout.write(renderExpressWaiting({
118
+ reason: progress.reason,
119
+ ...(progress.reason === 'throttled' ? { delayMs } : {}),
120
+ ...(progress.holder === undefined ? {} : { holder: progress.holder }),
121
+ ...(progress.holdReason === undefined ? {} : { holdReason: progress.holdReason }),
122
+ }) + '\n');
123
+ announcedWait = progress.reason;
124
+ },
125
+ });
126
+ landedId = landed.activity.id;
127
+ const fresh = await presentation();
128
+ const headerCount = fresh.participantCount;
129
+ const held = fresh.held;
130
+ const ownActCount = fresh.ownActivityCount;
131
+ const hasPending = pendingPublic.length > 0 || pendingRoomChanges.length > 0;
132
+ const pending = hasPending ? `\n\n${renderPendingFeed([...fresh.activities], [...pendingPublic], [...pendingRoomChanges], knownName, fresh.state)}` : '';
133
+ const hint = expressHintLine(ownActCount);
134
+ const reachEcho = landed.activity.reach === 'bell'
135
+ ? ' · to everyone (bell)'
136
+ : landed.activity.mentions.length > 0
137
+ ? ` · to ${landed.activity.mentions.map((target) => participantIdentity(target)).join(', ')}`
138
+ : '';
139
+ const confirmation = `● your activity lands${style('dim', ` — #${ownActCount} · ${landed.activity.id}${reachEcho}`)}`;
140
+ const withHint = hint ? `${confirmation}\n${style('dim', hint)}` : confirmation;
141
+ const reentry = reentered ? '● you stepped back into the square\n' : '';
142
+ process.stdout.write(withPathOutput(squarePath, reentry + withHint + pending, { participantCount: headerCount, held }));
143
+ }
144
+ catch (error) {
145
+ // Save before any recovery read: even a broken artifact must not eat the body.
146
+ const draftPath = saveActivityDraft(squarePath, name, rawInput);
147
+ const confirmedUnsent = landedId === undefined && (!expressAttempted || (isSquareError(error) && error.facts?.activityUnsent === true));
148
+ const fresh = await presentation().catch(() => undefined);
149
+ const output = { squarePath, name: knownName, draftPath, participantCount: fresh?.participantCount, held: fresh?.held };
150
+ if (!confirmedUnsent) {
151
+ process.stderr.write(renderActivityUncertain({ ...output, landedId, detail: error instanceof Error ? error.message : String(error) }));
152
+ process.exit(1);
153
+ return;
154
+ }
155
+ if (isSquareError(error) && fresh !== undefined) {
156
+ if (error.code === 'behind') {
157
+ process.stdout.write(renderActivityBlocked({
158
+ ...output,
159
+ retryCommand,
160
+ forceCommand,
161
+ activitySummaries: unreadActivitySummaries(fresh.state, knownName, nowMs()),
162
+ unreadRoomChanges: [],
163
+ }));
164
+ process.exit(1);
104
165
  return;
105
166
  }
106
- catch (error) {
107
- if (!(error instanceof SquareError))
108
- throw error;
109
- const freshSquare = await openSquare(squarePath, { clock: nowMs });
110
- const fresh = await activityPresentation(freshSquare, knownName).finally(() => closeOpenSquare(freshSquare));
111
- const headerCount = fresh.participantCount;
112
- const held = fresh.held;
113
- if (error.code === 'behind') {
114
- const draftPath = saveActivityDraft(squarePath, name, rawInput);
115
- process.stdout.write(renderActivityBlocked({
116
- squarePath,
117
- name: knownName,
118
- forceCommand: opts.forceCommand,
119
- activitySummaries: unreadActivitySummaries(fresh.state, knownName, nowMs()),
120
- unreadRoomChanges: [],
121
- draftPath,
122
- participantCount: headerCount,
123
- held,
124
- }));
125
- process.exit(1);
126
- return;
127
- }
128
- if (error.code === 'capped') {
129
- process.stdout.write(renderActivityLimit({
130
- squarePath,
131
- name: knownName,
132
- count: fresh.ownActivityCount,
133
- ...(fresh.hardCap === null ? {} : { hardCap: fresh.hardCap }),
134
- draftPath: saveActivityDraft(squarePath, name, rawInput),
135
- participantCount: headerCount,
136
- held,
137
- }));
138
- process.exit(1);
139
- return;
140
- }
141
- if (error.code === 'throttled') {
142
- const delayMs = error.facts?.retryAfterMs ?? SLEEP_MS;
143
- if (noWait) {
144
- const draftPath = saveActivityDraft(squarePath, name, rawInput);
145
- process.stdout.write(renderExpressNoWait({ squarePath, name: knownName, reason: 'throttled', delayMs, draftPath, participantCount: headerCount, held, forceCommand: opts.forceCommand }));
146
- process.exit(1);
147
- }
148
- if (announcedWait !== 'throttled') {
149
- process.stdout.write(renderExpressWaiting({ reason: 'throttled', delayMs }) + '\n');
150
- announcedWait = 'throttled';
151
- }
152
- await sleep(delayMs);
153
- continue;
154
- }
155
- if (error.code === 'held') {
156
- const holdReason = fresh.holdReason;
157
- if (noWait) {
158
- const draftPath = saveActivityDraft(squarePath, name, rawInput);
159
- process.stdout.write(renderExpressNoWait({ squarePath, name: knownName, reason: 'held', holdReason, draftPath, participantCount: headerCount, held, forceCommand: opts.forceCommand }));
160
- process.exit(1);
161
- }
162
- if (announcedWait !== 'held') {
163
- process.stdout.write(renderExpressWaiting({ reason: 'held' }) + '\n');
164
- announcedWait = 'held';
165
- }
166
- await sleep(SLEEP_MS);
167
- continue;
168
- }
169
- if (error.code === 'bell_quota') {
170
- process.stdout.write(withPathOutput(squarePath, [`✕ the bell stays quiet for now`, ` · you can ring it again in ${formatDuration(error.facts?.retryAfterMs ?? 1)}`].join('\n'), { participantCount: headerCount, held }));
171
- process.exit(1);
172
- return;
173
- }
174
- throw error;
167
+ if (error.code === 'capped') {
168
+ process.stdout.write(renderActivityLimit({
169
+ ...output,
170
+ count: fresh.ownActivityCount,
171
+ ...(fresh.hardCap === null ? {} : { hardCap: fresh.hardCap }),
172
+ }));
173
+ process.exit(1);
174
+ return;
175
+ }
176
+ if (error.code === 'throttled' || error.code === 'held') {
177
+ process.stdout.write(renderExpressNoWait({
178
+ ...output,
179
+ reason: error.code,
180
+ delayMs: error.facts?.retryAfterMs ?? SLEEP_MS,
181
+ holdReason: fresh.holdReason,
182
+ retryCommand,
183
+ }));
184
+ process.exit(1);
185
+ return;
186
+ }
187
+ if (error.code === 'bell_quota') {
188
+ process.stdout.write(withPathOutput(squarePath, [
189
+ '✕ the bell stays quiet for now',
190
+ ` · you can ring it again in ${formatDuration(error.facts?.retryAfterMs ?? 1)}`,
191
+ `· draft kept: ${draftPath}`,
192
+ ` · retry when the bell is ready:`,
193
+ `${retryCommand} < ${quoteShell(draftPath)}`,
194
+ ].join('\n'), output));
195
+ process.exit(1);
196
+ return;
175
197
  }
176
198
  }
177
- }
178
- finally {
179
- await square.close();
199
+ const lines = [error instanceof Error ? error.message : String(error), `· draft kept: ${draftPath}`];
200
+ if (isSquareError(error)) {
201
+ if (callerMissing || error.code === 'unknown_participant' || error.code === 'not_joined')
202
+ lines.push(joinRecoveryCommand(squarePath, name));
203
+ if (error.code === 'already_joined')
204
+ lines.push(...takeoverRecoveryLines(squarePath, name));
205
+ if (error.code === 'invalid_args' || error.code === 'invalid_name') {
206
+ if (opts.mentions?.length)
207
+ lines.push(participantsRecoveryCommand(squarePath));
208
+ if (opts.reply !== undefined)
209
+ lines.push(`${commandPrefix(squarePath)} history --limit 10`);
210
+ }
211
+ }
212
+ lines.push(' · correct the problem above before retrying; keep your intended recipients and reply:', `${retryCommand} < ${quoteShell(draftPath)}`);
213
+ process.stderr.write(formatRefusal(squarePath, lines, output));
214
+ process.exit(2);
180
215
  }
181
216
  }
@@ -20,6 +20,10 @@ export declare function createSquareFile(squarePath: string, state: SquareState)
20
20
  * conflicts. The URI remains create-disabled throughout the retry loop.
21
21
  */
22
22
  export declare function readSquareSnapshot(squarePath: string, signal?: AbortSignal): Promise<SquareSnapshot>;
23
+ /** Check the artifact revision without loading or parsing the historical state. */
24
+ export declare function readSquareRevision(squarePath: string, signal?: AbortSignal): Promise<number>;
25
+ /** One reference-counted revision detector per canonical artifact in this process. */
26
+ export declare function observeSquareRevision(squarePath: string): Promise<import('./file-changes.js').VersionObserver<number>>;
23
27
  export type SquareTransition<R> = (state: SquareState, revision: number) => {
24
28
  state?: SquareState;
25
29
  result: R;
package/dist/artifact.js CHANGED
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { setTimeout as sleep } from 'node:timers/promises';
7
7
  import { isDeepStrictEqual } from 'node:util';
8
+ import { observeFileVersion } from './file-changes.js';
8
9
  import { InternalSquareError, SquareError } from './model.js';
9
10
  import { validateSquareState } from './square-state.js';
10
11
  const APPLICATION_ID = 0x53515245;
@@ -86,19 +87,26 @@ function isOwnedSnapshotSchema(database) {
86
87
  && column.pk === expected.pk && column.hidden === expected.hidden;
87
88
  });
88
89
  }
90
+ function validateDatabaseHeader(database) {
91
+ const identity = database.prepare('SELECT application_id, user_version FROM pragma_application_id, pragma_user_version').get();
92
+ if (identity.application_id !== APPLICATION_ID)
93
+ throw invalidArtifact('unrelated SQLite database.');
94
+ if (identity.user_version !== USER_VERSION)
95
+ throw invalidArtifact('unsupported SQLite format version.');
96
+ if (!isOwnedSnapshotSchema(database))
97
+ throw invalidArtifact('unsupported snapshot schema.');
98
+ }
99
+ function validateSnapshotRevision(rows) {
100
+ if (rows.length !== 1 || rows[0].id !== 1 || !Number.isSafeInteger(rows[0].revision) || rows[0].revision < 0)
101
+ throw invalidArtifact('snapshot row is malformed.');
102
+ return rows[0].revision;
103
+ }
89
104
  function validateDatabase(database) {
90
105
  try {
91
- const identity = database.prepare('SELECT application_id, user_version FROM pragma_application_id, pragma_user_version').get();
92
- if (identity.application_id !== APPLICATION_ID)
93
- throw invalidArtifact('unrelated SQLite database.');
94
- if (identity.user_version !== USER_VERSION)
95
- throw invalidArtifact('unsupported SQLite format version.');
96
- if (!isOwnedSnapshotSchema(database))
97
- throw invalidArtifact('unsupported snapshot schema.');
106
+ validateDatabaseHeader(database);
98
107
  const rows = database.prepare(`SELECT id, revision, state FROM ${SNAPSHOT_TABLE}`).all();
99
- if (rows.length !== 1 || rows[0].id !== 1 || !Number.isSafeInteger(rows[0].revision) || rows[0].revision < 0)
100
- throw invalidArtifact('snapshot row is malformed.');
101
- return { state: parseState(rows[0].state), revision: rows[0].revision };
108
+ const revision = validateSnapshotRevision(rows);
109
+ return { state: parseState(rows[0].state), revision };
102
110
  }
103
111
  catch (error) {
104
112
  if (error instanceof SquareError)
@@ -112,12 +120,12 @@ function configureDatabase(database) {
112
120
  database.exec('PRAGMA busy_timeout = 0; PRAGMA journal_mode = DELETE; PRAGMA synchronous = FULL;');
113
121
  }
114
122
  /** Opens an existing artifact without permitting SQLite to create a missing path. */
115
- function readSquareSnapshotOnce(squarePath) {
123
+ function readExistingArtifact(squarePath, read) {
116
124
  requireSquareExtension(squarePath);
117
125
  let database;
118
126
  try {
119
127
  database = openExistingDatabase(squarePath);
120
- return validateDatabase(database);
128
+ return read(database);
121
129
  }
122
130
  catch (error) {
123
131
  if (error instanceof SquareError)
@@ -132,6 +140,16 @@ function readSquareSnapshotOnce(squarePath) {
132
140
  closeQuietly(database);
133
141
  }
134
142
  }
143
+ function readSquareSnapshotOnce(squarePath) {
144
+ return readExistingArtifact(squarePath, validateDatabase);
145
+ }
146
+ function readSquareRevisionOnce(squarePath) {
147
+ return readExistingArtifact(squarePath, (database) => {
148
+ validateDatabaseHeader(database);
149
+ const rows = database.prepare(`SELECT id, revision FROM ${SNAPSHOT_TABLE}`).all();
150
+ return validateSnapshotRevision(rows);
151
+ });
152
+ }
135
153
  async function createTemporaryArtifact(squarePath) {
136
154
  const directory = path.dirname(squarePath);
137
155
  await fs.promises.mkdir(directory, { recursive: true });
@@ -236,12 +254,23 @@ async function pauseForBusy(signal) {
236
254
  * Reads a complete validated snapshot, yielding between transient SQLite lock
237
255
  * conflicts. The URI remains create-disabled throughout the retry loop.
238
256
  */
239
- export async function readSquareSnapshot(squarePath, signal) {
257
+ export function readSquareSnapshot(squarePath, signal) {
258
+ return readWithBusyRetry(() => readSquareSnapshotOnce(squarePath), signal);
259
+ }
260
+ /** Check the artifact revision without loading or parsing the historical state. */
261
+ export function readSquareRevision(squarePath, signal) {
262
+ return readWithBusyRetry(() => readSquareRevisionOnce(squarePath), signal);
263
+ }
264
+ /** One reference-counted revision detector per canonical artifact in this process. */
265
+ export function observeSquareRevision(squarePath) {
266
+ return observeFileVersion(squarePath, 'square-revision', readSquareRevision);
267
+ }
268
+ async function readWithBusyRetry(read, signal) {
240
269
  while (true) {
241
270
  if (signal?.aborted)
242
271
  throw signal.reason ?? closedError();
243
272
  try {
244
- return readSquareSnapshotOnce(squarePath);
273
+ return read();
245
274
  }
246
275
  catch (error) {
247
276
  if (!isBusy(error))
@@ -11,4 +11,5 @@ export interface AttentionPreview {
11
11
  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
+ export declare function renderAttentionDescription({ actor, recipient, route }: Pick<AttentionPreview, 'actor' | 'recipient' | 'route'>): string;
14
15
  export declare function renderAttentionPreview(attention: AttentionPreview): string;
@@ -16,12 +16,14 @@ export function displayAttentionPath(squarePath) {
16
16
  ? `~${squarePath.slice(homedir().length)}`
17
17
  : squarePath;
18
18
  }
19
+ export function renderAttentionDescription({ actor, recipient, route }) {
20
+ switch (route) {
21
+ case 'mention': return `${actor} addressed you (${recipient})`;
22
+ case 'attention': return `${actor} spoke · you’re listening to ${actor}`;
23
+ case 'bell': return `${actor} rang the bell · everyone’s attention`;
24
+ }
25
+ }
19
26
  export function renderAttentionPreview(attention) {
20
- // A boundary preview only ever reaches the recipient's own session, so the
21
- // recipient is always "you"; the name follows in parens for recognition.
22
- const route = attention.route === 'bell'
23
- ? `${attention.actor} rang the bell for you (${attention.recipient})`
24
- : `${attention.actor} called your name (${attention.recipient})`;
25
27
  // The fence is what keeps the body from being re-rendered by Markdown. It grows past any
26
28
  // backtick run in the body so a body containing a fence cannot break out of this one.
27
29
  // Everything outside the fence is the system's voice, which participant bodies cannot fake.
@@ -29,7 +31,7 @@ export function renderAttentionPreview(attention) {
29
31
  return [
30
32
  `${fence}square-activity`,
31
33
  `· ${displayAttentionPath(attention.squarePath)} · ${formatActivityId(attention.actIndex)}`,
32
- `● ${route}`,
34
+ `● ${renderAttentionDescription(attention)}`,
33
35
  '',
34
36
  previewAttentionBody(attention.body),
35
37
  fence,
@@ -1,4 +1,5 @@
1
1
  import { promises as fs } from 'node:fs';
2
+ import { hostLedgerRoot } from './host-ledger-root.js';
2
3
  import path from 'node:path';
3
4
  import { openSquare } from './square-file-adapter.js';
4
5
  import { closeOpenSquare } from './open-square.js';
@@ -9,7 +10,7 @@ import { SquareError } from './model.js';
9
10
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
10
11
  import { projectSessionBindings } from './square-projections.js';
11
12
  import { claimSessionParticipant, readParticipantOwner, releaseSessionParticipant } from './registry.js';
12
- import { publishWakeRoute, retireWakeRoutesForSessionFromArtifact, resolvePrimaryWakeRoute, defaultWakeRouteCapabilities } from './routes.js';
13
+ import { publishWakeRoute, resolvePrimaryWakeRoute, defaultWakeRouteCapabilities } from './routes.js';
13
14
  export { automaticParticipant } from './participant-identity.js';
14
15
  const providerEnv = {
15
16
  codex: 'CODEX_THREAD_ID',
@@ -29,10 +30,9 @@ function operationEnv(provider, sessionId, env) {
29
30
  };
30
31
  }
31
32
  function hostLedgerForEnv(env) {
32
- const root = env.SQUARE_REGISTRY === undefined ? undefined : path.dirname(env.SQUARE_REGISTRY);
33
+ const root = hostLedgerRoot(env);
33
34
  return createHostLedgerPort({
34
- userPath: env.SQUARE_HOST_LEDGER_USER ?? root,
35
- localPath: env.SQUARE_HOST_LEDGER_LOCAL ?? root,
35
+ rootPath: root,
36
36
  });
37
37
  }
38
38
  export function publicSquarePath(cwd) {
@@ -64,7 +64,7 @@ export async function automaticSessionStart(provider, sessionId, cwd, env = proc
64
64
  let bindings;
65
65
  let entry;
66
66
  try {
67
- bindings = await hostLedger.listPresence({ location: squarePath, participant: name, scopes: ['user', 'local'] });
67
+ bindings = await hostLedger.listPresence({ location: squarePath, participant: name });
68
68
  entry = await entryPresentation(reader, name);
69
69
  }
70
70
  finally {
@@ -103,8 +103,7 @@ export async function automaticSessionStart(provider, sessionId, cwd, env = proc
103
103
  channel: provider === 'claude' ? 'claude-code' : provider,
104
104
  updatedAt: Date.now(),
105
105
  ...(claim?.epoch === undefined || claim.epoch <= 0 ? {} : { epoch: claim.epoch }),
106
- }, 'user');
107
- await square.reconcileBinding();
106
+ });
108
107
  return undefined;
109
108
  }
110
109
  catch (error) {
@@ -127,7 +126,6 @@ export async function automaticSessionEnd(provider, sessionId, cwd, env = proces
127
126
  hostLedger,
128
127
  sessionId,
129
128
  location: probe.location,
130
- scopes: ['user', 'local'],
131
129
  }))[0];
132
130
  const expectedEpoch = binding === undefined
133
131
  ? undefined
@@ -135,22 +133,14 @@ export async function automaticSessionEnd(provider, sessionId, cwd, env = proces
135
133
  await closeOpenSquare(probe);
136
134
  const square = await Square.at({ path: squarePath, hostLedger, env: scopedEnv });
137
135
  try {
138
- if (binding !== undefined) {
139
- const reader = await openSquare(squarePath);
140
- const joined = await entryPresentation(reader, binding.participant).finally(() => closeOpenSquare(reader));
141
- if (joined.joined) {
142
- const ended = await square.endOwnedSession(binding.participant, sessionId, expectedEpoch);
143
- if (ended !== null)
144
- await square.reconcileBinding();
145
- }
146
- }
147
- const cleanup = await openSquare(squarePath);
148
- try {
149
- await retireWakeRoutesForSessionFromArtifact(cleanup.artifact, { location: squarePath, sessionId });
150
- }
151
- finally {
152
- await closeOpenSquare(cleanup);
153
- }
136
+ // The ended owner's done, routes, and presence settle inside the ownership claim
137
+ // lock. A same-session rejoin or takeover that lands afterwards claims only after
138
+ // that cleanup, so this call never performs post-return ledger or route cleanup that
139
+ // could remove the replacement owner's fresh rows.
140
+ if (binding === undefined)
141
+ await square.retireEndedSessionRoutes(sessionId);
142
+ else
143
+ await square.endOwnedSession(binding.participant, sessionId, expectedEpoch);
154
144
  }
155
145
  finally {
156
146
  await square.close();