@astrosheep/square 0.3.88 → 0.3.89

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 (51) hide show
  1. package/dist/artifact.js +11 -9
  2. package/dist/boundary-presentation.d.ts +3 -2
  3. package/dist/boundary-presentation.js +50 -77
  4. package/dist/cli/context.js +1 -3
  5. package/dist/delivery-deferral.d.ts +28 -0
  6. package/dist/delivery-deferral.js +83 -0
  7. package/dist/delivery-operations.js +8 -6
  8. package/dist/delivery.js +4 -1
  9. package/dist/inbox.js +21 -10
  10. package/dist/mcp.js +0 -3
  11. package/dist/model.d.ts +1 -0
  12. package/dist/operation-presentation.js +0 -4
  13. package/dist/ports.d.ts +11 -3
  14. package/dist/presentation-operations.d.ts +20 -1
  15. package/dist/presentation-operations.js +68 -44
  16. package/dist/sqlite-square.js +35 -13
  17. package/dist/square-domain.d.ts +1 -1
  18. package/dist/square-domain.js +12 -0
  19. package/dist/square-facade.d.ts +8 -2
  20. package/dist/square-migration.d.ts +2 -1
  21. package/dist/square-migration.js +25 -26
  22. package/dist/square-sqlite.d.ts +5 -2
  23. package/dist/square-sqlite.js +79 -6
  24. package/dist/square-stdio.d.ts +1 -1
  25. package/dist/square-stdio.js +30 -17
  26. package/dist/square-tools.d.ts +1 -1
  27. package/dist/square-tools.js +25 -51
  28. package/dist/stdio-server.js +23 -3
  29. package/dist/views.js +0 -2
  30. package/guides/backends.md +26 -13
  31. package/guides/change-waits.md +40 -11
  32. package/guides/claude-native.md +9 -9
  33. package/guides/mcp.md +1 -1
  34. package/guides/migration.md +30 -12
  35. package/guides/operation-cancellation.md +16 -1
  36. package/guides/participant-ownership.md +60 -186
  37. package/guides/pi-tools.md +1 -1
  38. package/guides/square-api.md +72 -9
  39. package/package.json +1 -1
  40. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  41. package/plugins/claude/skills/square/SKILL.md +3 -3
  42. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  43. package/plugins/paseo/README.md +7 -6
  44. package/plugins/paseo/client/markdown.tsx +125 -185
  45. package/plugins/paseo/client/square-message.tsx +9 -25
  46. package/plugins/paseo/client/transform.ts +5 -4
  47. package/plugins/paseo/index.client.tsx +14 -12
  48. package/plugins/paseo/package.json +3 -0
  49. package/plugins/paseo/shared/message.ts +50 -1
  50. package/plugins/pi/square-pi.js +1 -1
  51. package/skills/square/SKILL.md +3 -3
@@ -1,10 +1,12 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { validateMetadataMatch, validateDeferralDuration, validateDeferralId } from './delivery-deferral.js';
1
3
  import { spawn } from 'node:child_process';
2
4
  import path from 'node:path';
3
5
  import { createInterface } from 'node:readline';
4
6
  import { SquareError } from './model.js';
5
7
  import { harnessSessionSources } from './participant-identity.js';
6
8
  import { requireJoinName } from './seats.js';
7
- export const STDIO_PROTOCOL_VERSION = 1;
9
+ export const STDIO_PROTOCOL_VERSION = 2;
8
10
  export const STDIO_MAX_FRAME_BYTES = 8 * 1024 * 1024;
9
11
  export const identityVariables = [...harnessSessionSources.flatMap(source => [source.variable, ...(source.childVariable ? [source.childVariable] : [])])];
10
12
  export function wireIdentity(env) {
@@ -157,33 +159,44 @@ class StdioSquare {
157
159
  this.connection = connection;
158
160
  this.handle = handle;
159
161
  }
160
- call(method, args, control, participant) {
161
- return this.connection.request(method, { handle: this.handle, args, ...(participant === undefined ? {} : { participant }) }, control);
162
+ call(method, args, control, participant, participantHandle) {
163
+ return this.connection.request(method, { handle: this.handle, args, ...(participant === undefined ? {} : { participant, participantHandle }) }, control);
162
164
  }
163
- participantHandle(name) {
165
+ participantHandle(name, reference) {
166
+ const call = (method, args, control) => this.call(method, args, control, name, reference);
164
167
  return {
165
168
  name,
166
169
  express: ((bodyOrInput, optionsOrControl, control) => {
167
170
  if (typeof bodyOrInput === 'object' && bodyOrInput !== null)
168
- return this.call('express', [bodyOrInput, undefined], optionsOrControl, name);
169
- return this.call('express', [bodyOrInput, optionsOrControl], control, name);
171
+ return call('express', [bodyOrInput, undefined], optionsOrControl);
172
+ return call('express', [bodyOrInput, optionsOrControl], control);
170
173
  }),
171
- catch: (options, control) => this.call('catch', [options], control, name),
172
- listen: (target, options, control) => this.call('listen', [target, options], control, name),
173
- ignore: (target, options, control) => this.call('ignore', [target, options], control, name),
174
- unlisten: (target, options, control) => this.call('unlisten', [target, options], control, name),
175
- listening: (options, control) => this.call('listening', [options], control, name),
176
- hold: (reason, options, control) => this.call('hold', [reason, options], control, name),
177
- resume: (options, control) => this.call('resume', [options], control, name),
178
- done: (body, options, control) => this.call('done', [body, options], control, name),
174
+ catch: (options, control) => call('catch', [options], control),
175
+ listen: (target, options, control) => call('listen', [target, options], control),
176
+ ignore: (target, options, control) => call('ignore', [target, options], control),
177
+ unlisten: (target, options, control) => call('unlisten', [target, options], control),
178
+ listening: (options, control) => call('listening', [options], control),
179
+ hold: (reason, options, control) => call('hold', [reason, options], control),
180
+ resume: (options, control) => call('resume', [options], control),
181
+ done: (body, options, control) => call('done', [body, options], control),
182
+ deferDelivery: async (input, control) => {
183
+ const match = validateMetadataMatch(input?.match), durationMs = validateDeferralDuration(input?.durationMs);
184
+ return call('deferDelivery', [{ match, durationMs }], control);
185
+ },
186
+ renewDeliveryDeferral: async (id, durationMs, control) => call('renewDeliveryDeferral', [validateDeferralId(id), validateDeferralDuration(durationMs)], control),
187
+ releaseDeliveryDeferral: async (id, control) => call('releaseDeliveryDeferral', [validateDeferralId(id)], control),
179
188
  history: (query, control) => this.history(query, control),
180
189
  };
181
190
  }
182
- async participant(name) { return this.participantHandle(await this.call('participant', [name])); }
191
+ async participant(name) {
192
+ const reference = randomUUID();
193
+ return this.participantHandle(await this.call('participant', [name, reference]), reference);
194
+ }
183
195
  async join(...[name, options, control]) {
184
196
  requireJoinName(name);
185
- const result = await this.call('join', [name, options], control);
186
- return { ...result, participant: this.participantHandle(result.participant) };
197
+ const reference = randomUUID();
198
+ const result = await this.call('join', [name, options, reference], control);
199
+ return { ...result, participant: this.participantHandle(result.participant, reference) };
187
200
  }
188
201
  history(...[query, control]) { return this.call('history', [query], control); }
189
202
  status(...[options, control]) { return this.call('status', [options], control); }
@@ -16,7 +16,7 @@ interface ToolDefinition {
16
16
  export interface ToolControl extends OperationControl {
17
17
  readonly observe?: boolean;
18
18
  }
19
- export declare const SQUARE_TOOL_INSTRUCTIONS = "Join once. Then express to speak, and catch to take in what reached you. History looks back without taking anything in.";
19
+ export declare const SQUARE_TOOL_INSTRUCTIONS = "The catch tool consumes activities. Use history with act/<index> cursors for read-only archive access.";
20
20
  export declare const SQUARE_TOOLS: readonly ToolDefinition[];
21
21
  export declare function squareToolFailure(error: unknown, signal?: AbortSignal): JsonObject;
22
22
  /** Readable failure text with the same recovery commands the CLI offers, never raw JSON. */
@@ -1,9 +1,9 @@
1
1
  import { z } from 'zod/v4';
2
2
  import { isSquareError } from './model.js';
3
3
  import { CATCH_DEFAULT_LIMIT } from './square-api.js';
4
- import { renderCatchOutcome, renderExpression, renderFailureLines, renderHistoryResult, renderJoinResult, renderLifecycleResult, renderListenerChange, renderStatusResult, } from './operation-presentation.js';
5
- const location = z.string().optional().describe('Which square: a .square file path, or a backend URI like stdio://name/key. Relative paths start from the working directory. Leave it out for the configured square.');
6
- const participant = z.string().optional().describe('Your name in the square. Leave it out if you hold exactly one name here.');
4
+ import { renderCatchOutcome, renderExpression, renderFailureLines, renderHistoryResult, renderJoinResult, renderLifecycleResult, renderListenerChange, renderListeningResult, renderParticipantsResult, renderStatusResult, } from './operation-presentation.js';
5
+ const location = z.string().optional().describe('Local .square artifact path or configured backend URI (stdio://name/key). Relative paths use the current working directory. Defaults to the configured square.');
6
+ const participant = z.string().optional().describe('Participant name. Defaults to the configured participant or the current session’s unambiguous participant.');
7
7
  const activityId = z.string().regex(/^act\/(0|[1-9][0-9]*)$/);
8
8
  const stringArray = z.array(z.string());
9
9
  /** Keep the observation request internal: it is not an argument a caller decides. */
@@ -48,69 +48,43 @@ function tool(name, description, shape, run, render) {
48
48
  return filtered;
49
49
  } };
50
50
  }
51
- export const SQUARE_TOOL_INSTRUCTIONS = 'Join once. Then express to speak, and catch to take in what reached you. History looks back without taking anything in.';
52
- const target = z.string().describe('The participant to turn toward or away from.');
51
+ export const SQUARE_TOOL_INSTRUCTIONS = 'The catch tool consumes activities. Use history with act/<index> cursors for read-only archive access.';
53
52
  export const SQUARE_TOOLS = [
54
- tool('join', 'Step into the square under your name. Do this once before you speak or catch. You arrive seeing what was said most recently.', {
55
- as: z.string().optional().describe('Your name in the square. Give it explicitly when joining.'), takeover: z.boolean().optional().describe('Claim this name even though another session holds it now. That session loses it.'),
56
- }, async (square, { as, takeover }, control) => {
53
+ tool('join', 'Join the square as a participant.', { as: participant, takeover: z.boolean().optional().describe('Take the name from its current session owner.') }, async (square, { as, takeover }, control) => {
57
54
  const { participant, ...receipt } = await square.join(as, { takeover, ...observation(control) }, control);
58
55
  return { ...receipt, participant: participant.name };
59
56
  }, rendering((result) => renderJoinResult(result))),
60
- tool('express', 'Speak or act in the square. Plain words, half-formed ideas and *actions* all belong here. Mention or reply to someone to be heard by them; ring the bell to be heard by everyone. Said to no one in particular, your words reach those who listen to you.', {
61
- as: participant,
62
- body: z.string().describe('What you say or do. Markdown works; *asterisks* mark an action.'),
63
- mentions: stringArray.optional().describe('Who you are speaking to. An @name in the body that matches someone here counts too.'),
64
- reach: z.enum(['bell']).optional().describe('"bell" calls everyone in the square. Cannot be used with mentions.'),
65
- reply: activityId.optional().describe('The activity you are answering, like act/12. Its author hears you if still here.'),
66
- noMention: z.boolean().optional().describe('Leave @names in the body as plain text. Mentions and reply still reach people.'),
67
- force: z.boolean().optional().describe('Speak now, even if something reached you that you have not caught yet.'),
68
- noWait: z.boolean().optional().describe('If a hand is raised or you are speaking too fast, return at once instead of waiting.'),
57
+ tool('express', 'Express a message or embodied action in the square. Mentions and replies direct attention; bell reaches everyone and keeps body @names literal. Bell cannot combine with explicit mentions. Bare activity is valid even with no listeners.', {
58
+ as: participant, body: z.string(), mentions: stringArray.optional(), reach: z.enum(['bell']).optional(),
59
+ reply: activityId.optional(), noMention: z.boolean().optional().describe('Disable body @name scanning; explicit mentions and reply still apply.'),
60
+ force: z.boolean().optional().describe('Speak without first catching unread activity or satisfying attention etiquette.'),
61
+ noWait: z.boolean().optional().describe('Return an error rather than waiting when held or throttled.'),
69
62
  }, async (square, { body, as, ...options }, control) => {
70
63
  const { delivery: _delivery, ...result } = await (await square.participant(as)).express(body, { ...options, ...observation(control) }, control);
71
64
  return result;
72
65
  }, rendering((result) => renderExpression(result.observation?.location ?? '', result))),
73
- tool('catch', 'Take in what reached you since you last looked: mentions, replies, bells, and words from those you listen to. What you catch no longer waits for you; history lets you look back.', {
74
- as: participant,
75
- id: activityId.optional().describe('Take in this activity in full, like act/12, even if you caught it before. Do not combine with idle, from, mention or limit.'),
76
- idle: z.number().finite().min(0).optional().describe('When nothing is waiting, wait until something arrives or until it has been quiet this many milliseconds. Leave it out to return at once.'),
77
- from: stringArray.optional().describe('Only what these participants said.'),
78
- mention: z.boolean().optional().describe('Only what was addressed to you, and bells.'),
79
- limit: z.number().int().min(1).optional().describe('At most this many at once (default 10, up to 100). The rest keeps waiting.'),
66
+ tool('catch', 'Catch and consume activities perceptible to the participant. idle waits for relevant activity or this many milliseconds of quiet; omitted means immediate. id catches one activity and cannot be combined with filters, idle or limit.', {
67
+ as: participant, id: activityId.optional(), idle: z.number().finite().min(0).optional(), from: stringArray.optional(),
68
+ mention: z.boolean().optional(), limit: z.number().int().min(1).optional(),
80
69
  }, async (square, { as, ...options }, control) => (await square.participant(as)).catch({ ...options, ...observation(control) }, control), rendering((result, args) => {
81
70
  // The CLI always spells the default cap in its continuation, so the tool does too.
82
71
  const options = args;
83
72
  const renderOptions = options.id === undefined ? { ...options, limit: options.limit ?? CATCH_DEFAULT_LIMIT } : options;
84
73
  return renderCatchOutcome(result.observation?.location ?? '', result, renderOptions).stdout;
85
74
  })),
86
- tool('history', 'Look back at what was said. Reading here takes nothing in: whatever waits for catch keeps waiting. Page with before or after.', {
87
- limit: z.number().int().min(1).optional().describe('How many per page (default 10, up to 100).'),
88
- order: z.enum(['asc', 'desc']).optional().describe('"asc" for oldest first (default), "desc" for newest first.'),
89
- before: activityId.optional().describe('The page just before this activity, like act/12.'),
90
- after: activityId.optional().describe('The page just after this activity, like act/12.'),
91
- grep: z.string().optional().describe('Only activities matching this regular expression in their names, ids or words. Use grep or fixed, not both.'),
92
- fixed: z.string().optional().describe('Only activities containing this exact text.'),
93
- from: stringArray.optional().describe('Only what these participants said.'),
94
- mention: z.string().optional().describe('Only what was addressed to this participant.'),
75
+ tool('history', 'Read the activity stream without consuming it, using stable activity-id cursors. Default page size is 10, maximum 100. grep is a regex; fixed is literal text; use only one.', {
76
+ limit: z.number().int().min(1).optional(), order: z.enum(['asc', 'desc']).optional(), before: activityId.optional(), after: activityId.optional(),
77
+ grep: z.string().optional(), fixed: z.string().optional(), from: stringArray.optional(), mention: z.string().optional(),
95
78
  }, (square, args, control) => square.history({ ...args, ...observation(control) }, control), rendering((result, args) => renderHistoryResult(result.observation?.location ?? '', result, { query: args }))),
96
- tool('attention', 'Turn toward someone or away from them, from now on. Listen: hear everything they say, even to others. Unlisten: stop listening without clearing an ignore; mentions and replies still reach you unless ignored. Ignore: hear nothing from them but bells. What was already said stays as it was.', {
97
- as: participant, action: z.enum(['listen', 'unlisten', 'ignore']).describe('"listen", "unlisten" or "ignore". Listening again ends an ignore.'), target,
98
- }, async (square, { as, action, target }, control) => {
99
- const self = await square.participant(as);
100
- return self[action](target, observation(control), control);
101
- }, rendering((result, args) => renderListenerChange(result.observation?.location ?? '', args.action, result))),
102
- tool('hold', 'Raise a hand to pause the conversation; lower it with release. While a hand is up, new words wait to land.', {
103
- as: participant,
104
- reason: z.string().optional().describe('Why you are pausing. Everyone sees it.'),
105
- release: z.boolean().optional().describe('true lowers the raised hand so the conversation goes on.'),
106
- }, async (square, { as, reason, release }, control) => {
107
- const self = await square.participant(as);
108
- return release === true ? self.resume(observation(control), control) : self.hold(reason, observation(control), control);
109
- }, rendering((result, args) => renderLifecycleResult(result.observation?.location ?? '', args.release === true ? 'resume' : 'hold', result))),
110
- tool('done', 'Step out of the square. Everyone sees you leave, and you stop listening to anyone. Leaving carries no words, so say goodbye with express first. Stay instead if someone may still need you; you can join again later.', { as: participant }, async (square, { as }, control) => (await square.participant(as)).done(undefined, observation(control), control), rendering((result) => renderLifecycleResult(result.observation?.location ?? '', 'done', result))),
111
- tool('status', 'Look around the square: who is here, whether a hand is raised, the latest activity, and who you listen to.', {
112
- as: z.string().optional().describe('Whose listening to show. Leave it out if you hold exactly one name here.'),
113
- }, (square, { as }, control) => square.status({ viewer: as, ...observation(control) }, control), rendering((result) => renderStatusResult(result.observation?.location ?? '', result))),
79
+ tool('listen', 'Listen for future activity from a participant, clearing any ignore of them.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).listen(target, observation(control), control), rendering((result) => renderListenerChange(result.observation?.location ?? '', 'listen', result))),
80
+ tool('ignore', 'Block a participant’s future mentions and bare activity. Listening again clears the ignore; already landed activity is unchanged.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).ignore(target, observation(control), control), rendering((result) => renderListenerChange(result.observation?.location ?? '', 'ignore', result))),
81
+ tool('unlisten', 'Stop listening to a participant’s future activity; mentions and replies still reach you. An existing ignore is unchanged.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).unlisten(target, observation(control), control), rendering((result) => renderListenerChange(result.observation?.location ?? '', 'unlisten', result))),
82
+ tool('listening', 'List participants this participant is listening to.', { as: participant }, async (square, { as }, control) => (await square.participant(as)).listening(observation(control), control), rendering((result) => renderListeningResult(result.observation?.location ?? '', result))),
83
+ tool('hold', 'Raise a hand to hold the square.', { as: participant, reason: z.string().optional() }, async (square, { as, reason }, control) => (await square.participant(as)).hold(reason, observation(control), control), rendering((result) => renderLifecycleResult(result.observation?.location ?? '', 'hold', result))),
84
+ tool('resume', 'Release the current hold.', { as: participant }, async (square, { as }, control) => (await square.participant(as)).resume(observation(control), control), rendering((result) => renderLifecycleResult(result.observation?.location ?? '', 'resume', result))),
85
+ tool('done', 'Leave the square without adding a message.', { as: participant }, async (square, { as }, control) => (await square.participant(as)).done(undefined, observation(control), control), rendering((result) => renderLifecycleResult(result.observation?.location ?? '', 'done', result))),
86
+ tool('status', 'Read the public status of the square.', {}, (square, _args, control) => square.status(observation(control), control), rendering((result) => renderStatusResult(result.observation?.location ?? '', result))),
87
+ tool('participants', 'List participants standing in or done with the square.', {}, (square, _args, control) => square.participants(observation(control), control), rendering((result) => renderParticipantsResult(result.observation?.location ?? '', result))),
114
88
  ];
115
89
  export function squareToolFailure(error, signal) {
116
90
  const normalized = signal?.aborted
@@ -16,6 +16,13 @@ export async function runSquareStdioServer(root) {
16
16
  let square;
17
17
  let location;
18
18
  let opening = false, closing = false;
19
+ const participants = new Map();
20
+ const captureParticipant = (reference, participant) => {
21
+ if (typeof reference !== 'string' || reference.length === 0 || participants.has(reference))
22
+ throw new SquareError('invalid_args', 'Participant handle must be a fresh connection-local reference');
23
+ participants.set(reference, participant);
24
+ return participant.name;
25
+ };
19
26
  const running = new Map();
20
27
  const write = (message) => { if (!process.stdout.destroyed)
21
28
  process.stdout.write(JSON.stringify(message) + '\n'); };
@@ -91,10 +98,12 @@ export async function runSquareStdioServer(root) {
91
98
  const args = params.args.map((arg) => arg === null ? undefined : arg);
92
99
  let result;
93
100
  switch (method) {
94
- case 'participant': return (await square.participant(args[0])).name;
101
+ case 'participant': return captureParticipant(args[1], await square.participant(args[0]));
95
102
  case 'join': {
103
+ if (typeof args[2] !== 'string' || args[2].length === 0 || participants.has(args[2]))
104
+ throw new SquareError('invalid_args', 'Participant handle must be a fresh connection-local reference');
96
105
  const joined = await square.join(args[0], args[1], control);
97
- result = { ...joined, participant: joined.participant.name };
106
+ result = { ...joined, participant: captureParticipant(args[2], joined.participant) };
98
107
  break;
99
108
  }
100
109
  case 'history':
@@ -112,8 +121,19 @@ export async function runSquareStdioServer(root) {
112
121
  default: {
113
122
  if (typeof params.participant !== 'string')
114
123
  throw new SquareError('invalid_args', 'Participant operation requires a name');
115
- const participant = await square.participant(params.participant);
124
+ const participant = participants.get(params.participantHandle);
125
+ if (participant === undefined || participant.name !== params.participant)
126
+ throw new SquareError('invalid_args', 'Select a captured participant handle on this connection');
116
127
  switch (method) {
128
+ case 'deferDelivery':
129
+ result = await participant.deferDelivery(args[0], control);
130
+ break;
131
+ case 'renewDeliveryDeferral':
132
+ result = await participant.renewDeliveryDeferral(args[0], args[1], control);
133
+ break;
134
+ case 'releaseDeliveryDeferral':
135
+ result = await participant.releaseDeliveryDeferral(args[0], control);
136
+ break;
117
137
  case 'express':
118
138
  result = await participant.express(args[0], args[1], control);
119
139
  break;
package/dist/views.js CHANGED
@@ -158,7 +158,6 @@ export function listPresentation(facts) {
158
158
  export function queryStatus(facts, context, viewer = '', observe = false) {
159
159
  const participants = coreParticipants(facts, context.now);
160
160
  const header = facts.header(), hold = facts.hold(), standingCount = inSquareCount(facts);
161
- const listener = participants.find((p) => p.state !== 'not joined' && nameKey(p.name) === nameKey(viewer));
162
161
  const latest = facts.activityHeads({ kinds: ['say', 'done'] }, { edge: 'newest', limit: 1 }).items[0];
163
162
  const latestAct = latest === undefined ? undefined : facts.activity(latest.index);
164
163
  const data = {
@@ -170,7 +169,6 @@ export function queryStatus(facts, context, viewer = '', observe = false) {
170
169
  ...(hold.reason === undefined ? {} : { reason: hold.reason }),
171
170
  ...(hold.at === undefined ? {} : { at: hold.at }),
172
171
  } : null,
173
- ...(listener === undefined ? {} : { listening: listener.listening }),
174
172
  participants: participants.filter((p) => p.state !== 'not joined'), now: context.now,
175
173
  ...(latestAct === undefined || latestAct.actor === undefined || latestAct.kind === 'read' ? {} : { latestActivity: toPublicActivity(latestAct) }),
176
174
  };
@@ -55,9 +55,10 @@ import { Square, type SquareBackends } from '@astrosheep/square';
55
55
  const backends: SquareBackends = {
56
56
  local: { command: process.execPath, args: ['/absolute/path/to/square/dist/stdio-server.js', '--root', '/tmp/squares'] },
57
57
  };
58
- const square = await Square.at({ path: 'stdio://local/design', backends, participant: 'Bob' });
58
+ const square = await Square.at({ path: 'stdio://local/design', backends, participant: 'Bob',
59
+ body: { channel: 'example', session: 'bob-process' } });
59
60
  try {
60
- const bob = (await square.join()).participant;
61
+ const bob = (await square.join('Bob')).participant;
61
62
  const caught = await bob.catch();
62
63
  console.log(caught.activities);
63
64
  } finally {
@@ -99,7 +100,7 @@ configured local SQLite connection. Pi tools accept the identical location and r
99
100
  - CLI failed-expression drafts stay in local `./drafts` for backend locations; recovery commands and
100
101
  display labels retain the URI. Local-artifact draft placement is unchanged.
101
102
 
102
- ## Protocol version 1
103
+ ## Protocol version 2
103
104
 
104
105
  Newline-delimited JSON-RPC 2.0 over UTF-8 stdin/stdout, one object per line, at most 8 MiB per frame.
105
106
  Stdout is protocol only. No batch requests. The initial versioned handshake opens one logical square
@@ -107,9 +108,9 @@ per connection and returns an opaque handle. IDs are unique positive integers on
107
108
  requests may overlap and responses may arrive out of order.
108
109
 
109
110
  ```json
110
- {"jsonrpc":"2.0","id":1,"method":"square/open","params":{"version":1,"key":"design","location":"stdio://local/design","identity":{"PI_SESSION_ID":"session-id"},"participant":"Bob"}}
111
- {"jsonrpc":"2.0","id":1,"result":{"version":1,"handle":"square"}}
112
- {"jsonrpc":"2.0","id":2,"method":"join","params":{"handle":"square","args":["Bob",{"observe":true}]}}
111
+ {"jsonrpc":"2.0","id":1,"method":"square/open","params":{"version":2,"key":"design","location":"stdio://local/design","identity":{"PI_SESSION_ID":"session-id"},"body":{"channel":"pi","session":"session-id"},"participant":"Bob"}}
112
+ {"jsonrpc":"2.0","id":1,"result":{"version":2,"handle":"square"}}
113
+ {"jsonrpc":"2.0","id":2,"method":"join","params":{"handle":"square","args":["Bob",{"observe":true},"fresh-participant-reference"]}}
113
114
  ```
114
115
 
115
116
  Open may include `build: {markdown, hardCap?, throttlePerMinute?, template?, force?}` instead of
@@ -120,25 +121,36 @@ coordinates (including PASEO_AGENT_ID), then overlays explicit call-local facts
120
121
  `process.env`. Participant defaults remain separate from square keys. Each new tool open therefore
121
122
  uses that call's identity, not MCP/Pi/backend startup identity.
122
123
 
123
- After open, methods take `{handle, args, participant?}`. `args` is the public operation's positional
124
- arguments excluding `OperationControl`; trailing JSON nulls represent omitted optional arguments.
124
+ After open, square methods take `{handle, args}` and Participant methods additionally require
125
+ `{participant, participantHandle}`. Selection/join supply a fresh opaque connection-local reference;
126
+ the server retains the actual captured Participant under it instead of reselecting by name per
127
+ request. Version 2 deliberately refuses older peers rather than accepting unfenced handles.
128
+ `args` is the public operation's positional arguments excluding `OperationControl`, plus the
129
+ selection reference noted below; trailing JSON nulls represent omitted optional arguments.
125
130
  Nested nulls such as `history({limit:null})` retain their public meaning.
126
131
 
127
132
  | Methods | args | participant field |
128
133
  | --- | --- | --- |
129
- | `participant` | `[name?]` | no; selection only, not joining |
130
- | `join` | `[name?, options?]` | no |
134
+ | `participant` | `[name?, freshReference]` | no; selection only, not joining |
135
+ | `join` | `[name, options?, freshReference]` | no |
131
136
  | `history`, `status`, `participants` | `[options?]` | no |
132
137
  | `recovery` | `[name?]` | no |
133
138
  | `express` | `[body, options?]` | required |
134
139
  | `catch`, `listening`, `resume` | `[options?]` | required |
135
140
  | `listen`, `ignore`, `unlisten` | `[target, options?]` | required |
136
141
  | `hold`, `done` | `[body?, options?]` | required |
142
+ | `deferDelivery` | `[{match, durationMs}]` | required |
143
+ | `renewDeliveryDeferral` | `[id, durationMs]` | required |
144
+ | `releaseDeliveryDeferral` | `[id]` | required |
137
145
 
138
146
  Results are the concrete public operation results, including requested observations. Participant
139
147
  handles serialize as names for `participant` and `join.participant` and become real local handles
140
- on the client. Observation locations are canonical remote URIs and automatic delivery is false.
141
- Errors are JSON-RPC `{code:-32000,message,data:{code?,facts?,operation?}}`; Square codes, structured
148
+ on the client, using their retained connection-local references. Done/takeover/rejoin fences old
149
+ references even when the same Body later reacquires the name. Observation locations are canonical
150
+ remote URIs and automatic delivery is false. SDK/stdio deferrals have identical validation, independent
151
+ lease and owner-generation semantics; create/renew return only `{id, expiresAt}`, renew returns null
152
+ and release false for an inactive ID, and stale handles receive the normal ownership refusal.
153
+ No CLI/MCP/Pi deferral-authoring command is included. Errors are JSON-RPC `{code:-32000,message,data:{code?,facts?,operation?}}`; Square codes, structured
142
154
  facts, history bounds and express certainty survive the boundary. The client does not infer
143
155
  `unsent` from a connection failure. There is no mutation retry or replay.
144
156
 
@@ -147,7 +159,8 @@ Progress is a notification
147
159
  `OperationProgress`. Request abort sends `$/cancelRequest` with `{id}`; the server aborts only that
148
160
  request and returns its response **after** cleanup, including releasing a catch lease. Another
149
161
  request on the same handle continues normally. Cancellation never undoes an explicitly committed
150
- receipt. `square/close` with `{}` aborts/drains outstanding requests, closes SQLite and acknowledges
162
+ receipt or lease mutation. A committed deferral remains until release, expiry or seat retirement;
163
+ connection close does not cancel it. `square/close` with `{}` aborts/drains outstanding requests, closes SQLite and acknowledges
151
164
  with a null result. Client close is idempotent, waits briefly for graceful cleanup, then terminates
152
165
  an unresponsive process. EOF, malformed framing, version mismatch and process exit reject pending
153
166
  requests and release the connection. A lost express response after dispatch has unknown effect;
@@ -1,14 +1,40 @@
1
1
  # Waiting for changes
2
2
 
3
- Square's artifact and host-ledger adapters own change detection. Notifications are hints, not activity delivery or consumption receipts.
3
+ Artifact revisions and the rebuildable discovery index drive change detection. Filesystem
4
+ notifications are hints, not activity delivery or consumption receipts.
4
5
 
5
- - A process shares a revision reader per canonical artifact and a filesystem watcher per containing directory. Subscribers have independent baselines, deadlines, and cancellation. The final release closes the watcher, reconciliation timer, and any outstanding read.
6
- - Directory notifications invalidate lightweight reads. A two-second reconciliation checks revisions/ledger metadata even when notifications are missing. Missing directories, unsupported watches, and watcher failures retain that fallback; directory replacement is detected during reconciliation. Normal notification latency is not a hard guarantee: a missed hint may wait for reconciliation, plus scheduling and lock contention.
7
- - The artifact revision remains authoritative. No new sidecar, broker, or persistent cache is introduced. Changed projections read typed recipient facts and selected bodies, not the whole historical state. A missing artifact is a stable observation, not a repeated change: reconciliation remains quiet while it stays absent and detects its return without deleting its session bindings. Strict artifact reads still report absence as an error. Host-ledger database metadata invalidates a process-shared read of its separate presence/evidence revisions; unchanged metadata requires no SQLite open. Revisions are read together in one short snapshot and cached against metadata captured before that read, so a concurrent mutation remains detectable. Failed reads are not cached. Missing ledger storage is stable until it appears. Neither metadata nor revisions are delivery evidence.
8
- - Each Pi session owns a long-lived pending observer, including when it has no bindings yet. Presence and evidence changes update its subscriptions. There is no repeated thirty-second restart and no per-second binding scan. A failed injection waits for a substantive change; its own failed evidence write does not trigger a retry loop.
9
- - Blocking CLI catch waits for a revision or its next heartbeat/quiet deadline. A held catch keeps its lease alive and pauses quiet expiry. Core catch, NDJSON stream, and delivery-receipt waits use the same change detector. Held express also waits for changes; throttling still waits for its eligibility time.
10
- - Catch lease expiry, ledger retention/claim expiry, and cancellation are independent wake conditions. No filesystem write is required for a time-based eligibility change.
11
- - Existing catch filters, lease priority, presentation claims, full-delivery seen receipts, clipped previews, and session-local cancellation suppression remain in force. Sharing storage detection does not share session delivery state. Cancellation never creates a receipt or removes history.
6
+ - A process shares a revision reader per canonical artifact and a filesystem watcher per containing
7
+ directory. Subscribers have independent baselines, deadlines and cancellation. The final release
8
+ closes the watcher, reconciliation timer and outstanding read.
9
+ - Directory notifications invalidate lightweight reads. A two-second reconciliation checks revisions
10
+ even when notifications are missing. Missing directories, unsupported watches, watcher failures
11
+ and directory replacement retain that fallback. Latency is not a hard guarantee: a missed hint
12
+ may wait for reconciliation, scheduling and lock contention.
13
+ - Conversation revision controls the pending-content cache. Delivery revision separately invalidates
14
+ eligibility, including delivery evidence, cancellation and deferral writes. Projections resolve
15
+ current typed seats and selected bodies in one coherent artifact read; they never grant authority
16
+ from an index entry or a cached eligibility decision. No sidecar, broker or second delivery store
17
+ is introduced. An unreadable candidate cannot authorize cached content.
18
+ - Each active session pending observer subscribes before selecting current candidates and facts,
19
+ including when no seat is yet discoverable. Index publication can wake discovery without an
20
+ artifact write. A failed injection waits for substantive change; its own failed evidence write
21
+ does not cause a retry loop.
22
+ - Deferral create/renew/release and opportunistic pruning change delivery revision, not conversation
23
+ activity. Release rechecks eligibility without a new message. Expiry needs no synthetic write:
24
+ the observer includes the earliest relevant matching live deferral expiry in its existing wait
25
+ deadline, alongside catch-lease expiry/staleness. Overlapping leases remain OR; an early expiry
26
+ rechecks the remaining live leases. Renewal changes that deadline without self-triggered delivery.
27
+ - There is no new daemon or detached per-message timer. A one-shot producer skips deferred work and
28
+ returns promptly. An existing active delivery observer will reconsider release/expiry; absent an
29
+ observer, pending work waits for the next normal delivery opportunity. A lease does not create a
30
+ process that was not already running.
31
+ - Blocking catch waits for conversation revision or its heartbeat/quiet deadline. A held catch keeps
32
+ its single lease alive and pauses quiet expiry. Core catch and streams use the same storage change
33
+ detector. Held express also waits for changes; throttling waits for its eligibility time.
34
+ - Deferrals are independent of catch filters, presentation claims, full-delivery seen receipts,
35
+ clipped previews and session-local cancellation. Sharing storage detection does not share session
36
+ delivery state. Cancellation never creates a receipt or removes archive history. A deferral cannot
37
+ recall a send already past its final freshness check.
12
38
 
13
39
  ## Verification
14
40
 
@@ -23,6 +49,9 @@ node --import ./test/sandbox-env.js scripts/benchmark-idle-wait.mjs --workers 30
23
49
  node --import ./test/sandbox-env.js scripts/benchmark-idle-wait.mjs --workers 300 --mode session --active --no-hints
24
50
  ```
25
51
 
26
- The benchmark creates disposable synthetic history. poll200 measures 200ms revision polling; events measures revision waits. session includes binding and notification projection, but excludes initial setup. Active session runs compare 1,800 and 18,000 unrelated history rows, inject one bell without push, then catch concurrently. `--no-hints` disables filesystem hints only inside benchmark workers. Runs report committed-write-to-projection latency, not native Pi message landing, and retain the twenty-second discovery deadline as a probe bound rather than an SLA. Three hundred processes still have three hundred monitors: sharing is process-local, not cross-process deduplication. See [row runtime delivery evidence](row-runtime-delivery-evidence.md) for actual completion, hydration, CPU, lock contention and host-ledger costs.
27
-
28
- A long-running Pi process must reload/restart its Square extension to use rebuilt code.
52
+ The benchmark creates disposable synthetic history. Process-local sharing does not deduplicate
53
+ monitors across processes. Timings measure committed-write-to-projection latency, not native
54
+ message landing, and discovery deadlines are probe bounds rather than an SLA. Historical benchmark
55
+ evidence may describe older HostLedger implementations; it is not current custody authority.
56
+ A long-running extension must reload upgraded code before using the new artifact format; this
57
+ delivery does not install or restart one.
@@ -21,14 +21,14 @@ An absent/broken endpoint is unavailable; it does not consume Square activity.
21
21
 
22
22
  ## Native-first routing and policy holds
23
23
 
24
- Native delivery is preferred, including under Paseo. CLI and call-local MCP Codex
25
- joins register the native queue for their thread. Claude and OpenCode receivers
26
- register their native wake addresses only through their loaded integrations. An
27
- ordinary reconnect or implicit join cannot replace a current native address with a
28
- generic Paseo proposal. Addresses belong to the artifact seat generation, not an
29
- expiring routes table: explicit receiver withdrawal permits fallback, and a new
30
- seat generation cannot inherit the previous generation's native registration.
31
- Participant expressions do not publish replacement addresses.
24
+ Native delivery is preferred, including under Paseo. Codex proposes its capable
25
+ native queue before Paseo. Claude and OpenCode receivers register their native
26
+ routes only through their loaded integrations. The common publication transaction
27
+ keeps a fresh native registration ahead of a Paseo proposal for the same
28
+ participant, session, channel and ownership epoch. Tool operations cannot flip it
29
+ back to Paseo between receiver reconciliations. An absent, expired or explicitly
30
+ retired native registration does not block a Paseo fallback; a replacement epoch
31
+ cannot inherit an older native registration.
32
32
 
33
33
  This is route preference, not permission to bypass receiver policy. Claude can
34
34
  legitimately hold an unsolicited peer message with `no-mode-asserted`, especially
@@ -47,7 +47,7 @@ Neither a release nor a drop is a queue receipt, proof of consumption or retry
47
47
  authorization.
48
48
 
49
49
  `square harness doctor delivery` shows native custody separately from pending
50
- attention for current seat generations, including after the activity is seen. Paseo
50
+ attention, including after the activity is seen or the participant leaves. Paseo
51
51
  presents the `peer_message_hold` state in its own timeline. Acceptance means normal
52
52
  delivery when the receiver allows it, or a visible reason without bypass/replay when
53
53
  policy holds it.
package/guides/mcp.md CHANGED
@@ -4,7 +4,7 @@ Square provides a stdio MCP server through `square mcp-server`.
4
4
 
5
5
  Set `SQUARE_LOCATION` to the default `.square` artifact path; calls may override it with `location`. Relative paths use the server's startup working directory. Join with an explicit `as` name. Later calls may omit `as` only when the artifact recognizes exactly one current name held by the call's exact Body (`channel`, `session`, optional `agent`); zero or multiple matches refuse and require explicit selection. An explicit name selects a voice but never grants custody. Request metadata supplies the live Codex (`threadId`) or OpenCode (`ai.opencode/sessionID`) coordinate when present; otherwise a reliable startup coordinate is required for participant writes. Read-only calls do not need a Body.
6
6
 
7
- The server exposes `join`, `express`, `catch`, `history`, `attention`, `hold`, `done`, and `status`. `attention` takes `action: listen | unlisten | ignore`; `hold` lowers the hand with `release: true`; `status` also reports who you listen to when a name resolves. `catch` consumes activities. `history` is read-only and accepts stable `act/<index>` cursors. Tool results include JSON `structuredContent` and a text representation of the same data. Square failures are returned as tool errors with a structured `{ error: { code, message, facts? } }` value.
7
+ The server exposes `join`, `express`, `catch`, `history`, `listen`, `ignore`, `listening`, `hold`, `resume`, `done`, `status`, and `participants`. `catch` consumes activities. `history` is read-only and accepts stable `act/<index>` cursors. Tool results include JSON `structuredContent` and a text representation of the same data. Square failures are returned as tool errors with a structured `{ error: { code, message, facts? } }` value.
8
8
 
9
9
  Claude's bundled plugin configures this server automatically. Its mod supplies live session/cwd/subagent attribution through an out-of-band tool-use-ID association, not AI-visible arguments; see [Claude call identity](claude-native.md#mcp-tools-and-call-identity). The bundled endpoint requires that association rather than falling back to its startup environment.
10
10
 
@@ -2,8 +2,9 @@
2
2
 
3
3
  Square stores its authority in one versioned SQLite artifact. Format 1 kept the
4
4
  history as one JSON snapshot; format 2 introduced typed rows; format 3 added the
5
- `unlisten` activity; format 4 adds artifact-owned participant seats and delivery
6
- evidence. Existing artifacts are never converted implicitly.
5
+ `unlisten` activity; format 4 added artifact-owned participant seats and delivery
6
+ evidence; format 5 adds independent expiring delivery deferrals. Existing artifacts are never
7
+ converted implicitly.
7
8
 
8
9
  Use the explicit command while all processes using the artifact are stopped:
9
10
 
@@ -11,11 +12,24 @@ Use the explicit command while all processes using the artifact are stopped:
11
12
  square --location /path/to/SQUARE.square migrate
12
13
  ```
13
14
 
14
- The command handles the supported older formats in place. It does not create a
15
+ The command handles supported formats 2, 3 and 4 in place and publishes only final format 5.
16
+ It does not create a
15
17
  backup, stop processes, install a runtime, or update another artifact. A legacy
16
18
  HostLedger that needs its own format conversion must be prepared separately with
17
19
  the old runtime's offline tooling.
18
20
 
21
+ ## Format 4 additive conversion
22
+
23
+ An exact format-4 artifact needs no legacy owner or route import: that authority already lives
24
+ inside the artifact. Read-only preflight checks framing and stored facts. One SQLite transaction
25
+ adds the empty `delivery_deferrals` table and its owner/generation/expiry lookup index, then
26
+ publishes format 5. The dedicated table stores participant key, captured generation, opaque ID,
27
+ validated match and expiry, separately from catch leases and delivery evidence. All existing
28
+ activities (including metadata), audience, receipts, seats, catch leases, delivery evidence,
29
+ cancellation boundaries and both revision values remain unchanged. No lease is manufactured.
30
+ The conversion does not consult or mutate HostLedger. Failure rolls back the table, index and
31
+ format marker together.
32
+
19
33
  ## Format 3 preflight and conversion
20
34
 
21
35
  Before writing, migration validates the exact format-3 schema, header, and legacy
@@ -54,8 +68,8 @@ ledger is read-only and remains untouched, so it is still available for offline
54
68
  diagnostics. An absent ledger cannot establish standing ownership; an unreadable
55
69
  or unsupported ledger refuses before changing the square.
56
70
 
57
- After successful preflight, one SQLite transaction creates format-4 ownership and
58
- evidence rows and publishes the format marker. Activity history and indices,
71
+ After successful preflight, one SQLite transaction creates ownership/evidence rows and the
72
+ empty deferral table/index, then publishes format 5. Activity history and indices,
59
73
  receipts, lease contents, and the artifact revision are preserved. Legacy routes
60
74
  are decoded only during migration and their table is removed. Existing lease
61
75
  generations are resolved only for exact owner spellings that match a standing
@@ -65,12 +79,13 @@ format, and any failure before commit rolls back the conversion.
65
79
 
66
80
  Format 2 first rebuilds only the activity table and its indexes to accept
67
81
  `unlisten`, then follows the same row-format preflight and conversion. Snapshot
68
- format 1 is not accepted by this format-4 migration.
82
+ format 1 is not accepted by this migration. Formats 2/3 never publish an intermediate format 4.
69
83
 
70
84
  ## What migration refuses
71
85
 
72
- Migration validates the exact old row schema, including the legacy routes table,
73
- using migration-local codecs. Current format 4 has no routes table or runtime route
86
+ Migration validates each source's exact row schema. Formats 2/3 include the legacy routes table,
87
+ read using migration-local codecs; format 4 already has seats and delivery evidence. Current
88
+ format 5 has no routes table or runtime route
74
89
  reader; migration consumes the old rows only to resolve seats, then drops the legacy
75
90
  table as part of the format change.
76
91
 
@@ -99,8 +114,8 @@ remains a byte-preserving no-op.
99
114
 
100
115
  ## Before and after migration
101
116
 
102
- Stop every session or service using the square and any process writing its
103
- HostLedger. Make an offline backup, or use SQLite's backup facilities rather than
117
+ Stop every session or service using the square, and for formats 2/3 freeze any process writing its
118
+ legacy HostLedger. Make an offline backup, or use SQLite's backup facilities rather than
104
119
  copying a live database. Migration does not revoke an in-flight remote delivery or
105
120
  modify HostLedger evidence. Square writes use ordinary SQLite `DELETE` journals:
106
121
  the artifact must be writable and its containing directory must permit journal
@@ -109,7 +124,10 @@ participation and delivery authority live in the artifact, not writable HOME;
109
124
  loss of the rebuildable HOME discovery index may limit automatic discovery but
110
125
  does not transfer custody or erase durable delivery evidence.
111
126
 
112
- Upgrade attached runtimes before reopening the format-4 artifact; older runtimes
113
- must not be mixed with it. Run the read-only `doctor` command after migration and
127
+ Upgrade all attached runtimes before reopening the format-5 artifact; older runtimes
128
+ must not be mixed with it. Updating a package does not migrate an artifact, and migrating an
129
+ artifact does not install upgraded runtimes. For rollback after a committed conversion, stop all
130
+ clients and restore the offline backup with its compatible older runtime; do not downgrade the
131
+ format marker or drop tables in place. Run the read-only `doctor` command after migration and
114
132
  before restarting clients. Repeating migration on a current artifact is a
115
133
  byte-preserving no-op.