@astrosheep/square 0.3.101 → 0.3.102

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.
package/dist/mcp.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { SQUARE_TOOLS, SQUARE_TOOL_INSTRUCTIONS, squareToolsEnabled, squareToolFailure, squareToolFailureText } from './square-tools.js';
3
+ import { SQUARE_TOOLS, SQUARE_TOOL_INSTRUCTIONS, SquareExpressDrafts, squareToolsEnabled, squareToolFailure, squareToolFailureText } from './square-tools.js';
4
4
  import { operationEnv } from './participant-identity.js';
5
5
  import { codexWakeAddress } from './codex-address.js';
6
6
  import { readClaudeMcpCall } from './claude-mcp-context.js';
@@ -12,6 +12,7 @@ export function createSquareMcpServer(options) {
12
12
  const baseEnv = { ...(resourceOptions.env ?? process.env) };
13
13
  const toolsEnabled = squareToolsEnabled(baseEnv);
14
14
  const resources = createSquareResourceOwner();
15
+ const drafts = new SquareExpressDrafts();
15
16
  // Calls to the configured square borrow one server-owned connection.
16
17
  const inFlight = new Set();
17
18
  const server = new McpServer({ name: 'square', version: SQUARE_IDENTITY.packageVersion }, {
@@ -22,21 +23,30 @@ export function createSquareMcpServer(options) {
22
23
  server.registerTool(tool.name, { description: tool.description, inputSchema: tool.inputSchema }, (args, extra) => {
23
24
  const call = (async () => {
24
25
  let square;
26
+ let toolArgs = args;
27
+ let owner;
28
+ let draftToken;
29
+ let expressAttempted = false;
25
30
  try {
26
- const { location } = args;
27
31
  const threadId = extra._meta?.threadId;
28
32
  const opencodeSessionId = extra._meta?.['ai.opencode/sessionID'];
29
33
  // Claude's plugin endpoint requires the mod's call-local coordinate, never startup identity.
30
34
  const claude = baseEnv.SQUARE_CLAUDE_MCP_CONTEXT === '1'
31
35
  ? await readClaudeMcpCall(extra._meta?.['claudecode/toolUseId'], baseEnv)
32
36
  : undefined;
33
- const owner = claude !== undefined
37
+ owner = claude !== undefined
34
38
  ? harnessOwner({ channel: 'claude-code', session: claude.sessionId, ...(claude.agentId === undefined ? {} : { agent: claude.agentId }) })
35
39
  : typeof threadId === 'string' && threadId.trim()
36
40
  ? harnessOwner({ channel: 'codex', session: threadId })
37
41
  : typeof opencodeSessionId === 'string' && opencodeSessionId.trim()
38
42
  ? harnessOwner({ channel: 'opencode', session: opencodeSessionId })
39
43
  : configuredOwner;
44
+ if (tool.name === 'express') {
45
+ const resolved = drafts.resolve(toolArgs, owner);
46
+ toolArgs = resolved.args;
47
+ draftToken = resolved.token;
48
+ }
49
+ const { location } = toolArgs;
40
50
  const env = claude !== undefined
41
51
  ? { ...operationEnv('claude', claude.sessionId, baseEnv),
42
52
  // Preserve Claude's session + child flag semantics; an agent loop ID is not a session ID.
@@ -59,10 +69,12 @@ export function createSquareMcpServer(options) {
59
69
  ? wakeAddress : undefined;
60
70
  let text;
61
71
  try {
62
- text = await tool.run(square, args, { signal: extra.signal }, {
72
+ expressAttempted = tool.name === 'express';
73
+ text = await tool.run(square, toolArgs, { signal: extra.signal }, {
63
74
  owner, wakeAddress: claude === undefined && typeof threadId === 'string' && threadId.trim()
64
75
  ? codexWakeAddress(threadId, env) : callWake,
65
76
  });
77
+ drafts.consume(draftToken);
66
78
  }
67
79
  finally {
68
80
  await square.close();
@@ -71,10 +83,12 @@ export function createSquareMcpServer(options) {
71
83
  }
72
84
  catch (error) {
73
85
  const failure = squareToolFailure(error, extra.signal);
74
- const participant = args.as;
86
+ const participant = toolArgs.as;
87
+ const draft = tool.name === 'express' ? drafts.retain(toolArgs, owner, draftToken, failure, expressAttempted) : undefined;
75
88
  return { isError: true, content: [{ type: 'text', text: squareToolFailureText(failure, {
76
89
  location: square?.location ?? options.path,
77
90
  participant: typeof participant === 'string' ? participant : undefined,
91
+ draft,
78
92
  }) }] };
79
93
  }
80
94
  })();
@@ -6,6 +6,16 @@ export interface ToolIdentity {
6
6
  readonly wakeAddress?: WakeAddress;
7
7
  }
8
8
  type JsonObject = Record<string, unknown>;
9
+ export declare class SquareExpressDrafts {
10
+ private readonly drafts;
11
+ private purgeExpired;
12
+ resolve(args: JsonObject, owner?: Owner): {
13
+ args: JsonObject;
14
+ token?: string;
15
+ };
16
+ consume(token?: string): void;
17
+ retain(args: JsonObject, owner: Owner | undefined, token: string | undefined, failure: JsonObject, expressAttempted: boolean): string | undefined;
18
+ }
9
19
  interface ToolDefinition {
10
20
  name: string;
11
21
  description: string;
@@ -21,5 +31,6 @@ export declare function squareToolFailure(error: unknown, signal?: AbortSignal):
21
31
  export declare function squareToolFailureText(failure: JsonObject, context?: {
22
32
  location?: string;
23
33
  participant?: string;
34
+ draft?: string;
24
35
  }): string;
25
36
  export {};
@@ -1,7 +1,50 @@
1
+ import crypto from 'node:crypto';
1
2
  import { z } from 'zod/v4';
2
3
  import { isSquareError, SquareError } from './model.js';
3
4
  import { requireOwner, requireJoinName, selectOwnSeat, CATCH_DEFAULT_LIMIT } from './square-api.js';
4
5
  import { renderCatchOutcome, renderExpression, renderFailureLines, renderHistoryResult, renderJoinResult, renderLifecycleResult, renderListenerChange, renderStatusResult, } from './operation-presentation.js';
6
+ /** Process-local recovery: FIFO 64, fixed two-hour TTL, consumed only by a successful express. */
7
+ const EXPRESS_DRAFT_TTL_MS = 2 * 60 * 60 * 1000;
8
+ const EXPRESS_DRAFT_LIMIT = 64;
9
+ export class SquareExpressDrafts {
10
+ drafts = new Map();
11
+ purgeExpired() {
12
+ const now = Date.now();
13
+ for (const [token, draft] of this.drafts)
14
+ if (draft.expiresAt <= now)
15
+ this.drafts.delete(token);
16
+ }
17
+ resolve(args, owner) {
18
+ if (args.draft === undefined)
19
+ return { args };
20
+ this.purgeExpired();
21
+ if (typeof args.draft !== 'string' || Object.keys(args).length !== 1)
22
+ throw new SquareError('invalid_args', 'An express draft token cannot be combined with other arguments.');
23
+ const draft = this.drafts.get(args.draft);
24
+ if (draft === undefined || draft.owner !== owner)
25
+ throw new SquareError('invalid_args', 'Unknown, expired, or inaccessible express draft.');
26
+ return { args: draft.args, token: args.draft };
27
+ }
28
+ consume(token) {
29
+ if (token !== undefined)
30
+ this.drafts.delete(token);
31
+ }
32
+ retain(args, owner, token, failure, expressAttempted) {
33
+ const effect = failure.operation?.expressEffect;
34
+ // After an attempt, only an admission or argument refusal proves that retrying cannot duplicate an activity.
35
+ const unsent = !expressAttempted || effect === 'unsent' || (effect === undefined && failure.code === 'invalid_args');
36
+ if (!unsent || effect === 'landed' || effect === 'unknown' || typeof args.body !== 'string' || args.draft !== undefined)
37
+ return undefined;
38
+ this.purgeExpired();
39
+ if (token !== undefined && this.drafts.has(token))
40
+ return token;
41
+ const created = `dr_${crypto.randomBytes(16).toString('base64url')}`;
42
+ this.drafts.set(created, { owner, args, expiresAt: Date.now() + EXPRESS_DRAFT_TTL_MS });
43
+ if (this.drafts.size > EXPRESS_DRAFT_LIMIT)
44
+ this.drafts.delete(this.drafts.keys().next().value);
45
+ return created;
46
+ }
47
+ }
5
48
  const location = z.string().optional().describe('Which square: a .square file path. Relative paths start from the working directory. Leave it out for the configured square.');
6
49
  const participant = z.string().optional().describe('Your name in the square. Leave it out if you hold exactly one name here.');
7
50
  const activityId = z.string().regex(/^act\/(0|[1-9][0-9]*)$/);
@@ -45,14 +88,21 @@ export const SQUARE_TOOLS = [
45
88
  }, (square, { as, takeover }, control, identity) => square.join(requireJoinName(as), { ...identity, owner: requireOwner(identity.owner), takeover, observe: true }, control), rendering((result) => renderJoinResult(result))),
46
89
  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.', {
47
90
  as: participant,
48
- body: z.string().describe('What you say or do. Markdown works; *asterisks* mark an action.'),
91
+ body: z.string().optional().describe('What you say or do. Markdown works; *asterisks* mark an action. Required unless retrying with a draft token.'),
92
+ draft: z.string().optional().describe('Retry a failed express kept in this process. Pass only the token; the original body and options are restored.'),
49
93
  mentions: stringArray.optional().describe('Who you are speaking to. An @name in the body that matches someone here counts too.'),
50
94
  reach: z.enum(['bell']).optional().describe('"bell" calls everyone in the square. Cannot be used with mentions.'),
51
95
  reply: activityId.optional().describe('The activity you are answering, like act/12. Its author hears you if still here.'),
52
96
  noMention: z.boolean().optional().describe('Leave @names in the body as plain text. Mentions and reply still reach people.'),
53
97
  force: z.boolean().optional().describe('Speak now, even if something reached you that you have not caught yet.'),
54
98
  noWait: z.boolean().optional().describe('If you are speaking too fast, return at once instead of waiting.'),
55
- }, async (square, { body, as, ...options }, control, identity) => (await selectOwnSeat(square, identity.owner, as)).express(body, { ...options, observe: true }, control), rendering((result) => renderExpression(result.observation?.location ?? '', result))),
99
+ }, async (square, { body, draft, as, ...options }, control, identity) => {
100
+ if (draft !== undefined)
101
+ throw new SquareError('invalid_args', 'Unknown or expired express draft.');
102
+ if (body === undefined)
103
+ throw new SquareError('invalid_args', 'Express needs a body or a draft token.');
104
+ return (await selectOwnSeat(square, identity.owner, as)).express(body, { ...options, observe: true }, control);
105
+ }, rendering((result) => renderExpression(result.observation?.location ?? '', result))),
56
106
  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.', {
57
107
  as: participant,
58
108
  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.'),
@@ -103,5 +153,15 @@ export function squareToolFailure(error, signal) {
103
153
  }
104
154
  /** Readable failure text with the same recovery commands the CLI offers, never raw JSON. */
105
155
  export function squareToolFailureText(failure, context = {}) {
106
- return renderFailureLines(failure, { squarePath: context.location, name: context.participant }).join('\n');
156
+ const report = failure;
157
+ const lines = renderFailureLines(report, { squarePath: context.location, name: context.participant });
158
+ if (report.code === 'behind')
159
+ lines[0] = `✕ your words stay unsent — ${report.message}`;
160
+ if (context.draft !== undefined) {
161
+ lines.push(`· draft kept in memory: ${context.draft}`);
162
+ lines.push(report.code === 'behind'
163
+ ? '· catch the pending activity, then retry express with only:'
164
+ : '· correct the problem above, then retry express with only:', `{ "draft": "${context.draft}" }`);
165
+ }
166
+ return lines.join('\n');
107
167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.101",
3
+ "version": "0.3.102",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.101",
3
+ "version": "0.3.102",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.101",
3
+ "version": "0.3.102",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -3,7 +3,7 @@ import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui';
3
3
  import { z } from 'zod/v4';
4
4
 
5
5
  import { Square, harnessOwner } from '../../dist/square-api.js';
6
- import { SQUARE_TOOLS, SQUARE_TOOL_INSTRUCTIONS, squareToolsEnabled, squareToolFailure, squareToolFailureText } from '../../dist/square-tools.js';
6
+ import { SQUARE_TOOLS, SQUARE_TOOL_INSTRUCTIONS, SquareExpressDrafts, squareToolsEnabled, squareToolFailure, squareToolFailureText } from '../../dist/square-tools.js';
7
7
 
8
8
  import { presentPendingAtBoundary, renderPendingAtBoundary } from '../../dist/boundary-presentation.js';
9
9
  import { operationEnv } from '../../dist/participant-identity.js';
@@ -143,6 +143,7 @@ export function renderSquareCard(message, { expanded, outputPad = 1 }, theme) {
143
143
  }
144
144
 
145
145
  export default function squarePiExtension(pi) {
146
+ const drafts = new SquareExpressDrafts();
146
147
  if (squareToolsEnabled(process.env)) for (const tool of SQUARE_TOOLS) {
147
148
  pi.registerTool({
148
149
  name: `square_${tool.name}`,
@@ -152,20 +153,40 @@ export default function squarePiExtension(pi) {
152
153
  exposure: 'codemode',
153
154
  namespace: { name: 'square', description: 'A shared square where agents gather to talk, think, and act together.', instructions: SQUARE_TOOL_INSTRUCTIONS },
154
155
  async execute(_id, args, signal, _onUpdate, ctx) {
156
+ let toolArgs = args;
157
+ let owner;
158
+ let draftToken;
159
+ let expressAttempted = false;
160
+ let square;
155
161
  try {
156
162
  const sessionId = ctx.sessionManager.getSessionId();
157
- const square = await Square.at({
163
+ owner = harnessOwner({ channel: 'pi', session: sessionId });
164
+ if (tool.name === 'express') {
165
+ const resolved = drafts.resolve(toolArgs, owner);
166
+ toolArgs = resolved.args;
167
+ draftToken = resolved.token;
168
+ }
169
+ square = await Square.at({
158
170
  cwd: ctx.cwd,
159
171
  env: operationEnv('pi', sessionId, process.env),
160
- path: args.location,
172
+ path: toolArgs.location,
161
173
  }, { signal });
162
174
  let text;
163
- try { text = await tool.run(square, args, { signal }, { owner: harnessOwner({ channel: 'pi', session: sessionId }) }); }
175
+ try {
176
+ expressAttempted = tool.name === 'express';
177
+ text = await tool.run(square, toolArgs, { signal }, { owner });
178
+ drafts.consume(draftToken);
179
+ }
164
180
  finally { await square.close(); }
165
181
  return { content: [{ type: 'text', text }] };
166
182
  } catch (error) {
167
183
  const failure = squareToolFailure(error, signal);
168
- return { isError: true, content: [{ type: 'text', text: squareToolFailureText(failure, { location: args.location ?? process.env.SQUARE_LOCATION, participant: args.as }) }] };
184
+ const draft = tool.name === 'express' ? drafts.retain(toolArgs, owner, draftToken, failure, expressAttempted) : undefined;
185
+ return { isError: true, content: [{ type: 'text', text: squareToolFailureText(failure, {
186
+ location: square?.location ?? toolArgs.location ?? process.env.SQUARE_LOCATION,
187
+ participant: toolArgs.as,
188
+ draft,
189
+ }) }] };
169
190
  }
170
191
  },
171
192
  });