@astrosheep/square 0.3.73 → 0.3.74

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.
@@ -1,5 +1,12 @@
1
1
  import type { InboxMembership } from './model.js';
2
2
  export { pendingAtBoundary } from './square-projections.js';
3
3
  export declare function renderPendingAtBoundary(inbox: InboxMembership[]): string;
4
- /** Serialize a boundary across hook processes so one stale inbox cannot render twice. */
4
+ /**
5
+ * Serialize a boundary across hook processes so one stale inbox cannot render twice.
6
+ *
7
+ * `lookup` returns the session's memberships for this boundary. It may reuse an observation it has
8
+ * verified against the current presence binding and artifact revision; the boundary still projects
9
+ * presentation evidence live, and `presentPending` still re-checks the pending head, join, ownership
10
+ * claim and seen CAS. A source that cannot verify its reuse must read the inbox again.
11
+ */
5
12
  export declare function presentPendingAtBoundary<T>(sessionId: string, present: (context: string) => T | Promise<T>, lookup?: (sessionId: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal) => Promise<InboxMembership[]> | InboxMembership[], env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<T | undefined>;
@@ -140,7 +140,14 @@ async function presentPendingAtBoundaryUnlocked(sessionId, present, lookup = ses
140
140
  }
141
141
  return result;
142
142
  }
143
- /** Serialize a boundary across hook processes so one stale inbox cannot render twice. */
143
+ /**
144
+ * Serialize a boundary across hook processes so one stale inbox cannot render twice.
145
+ *
146
+ * `lookup` returns the session's memberships for this boundary. It may reuse an observation it has
147
+ * verified against the current presence binding and artifact revision; the boundary still projects
148
+ * presentation evidence live, and `presentPending` still re-checks the pending head, join, ownership
149
+ * claim and seen CAS. A source that cannot verify its reuse must read the inbox again.
150
+ */
144
151
  export async function presentPendingAtBoundary(sessionId, present, lookup = sessionInbox, env = process.env, signal) {
145
152
  const ledgerRoot = hostLedgerRoot(env);
146
153
  const ownerKey = createHash('sha256').update(sessionId).digest('hex');
@@ -33,5 +33,5 @@ export interface ParsedGlobalArgs {
33
33
  export declare function parseGlobalArgs(rawArgs: string[]): Promise<ParsedGlobalArgs>;
34
34
  export declare function locationIsRequired(command: string): boolean;
35
35
  export declare function defaultContext(command: string, squarePath?: string, name?: string): CommandContext;
36
- export declare function applicationFor(context: CommandContext): import("../square-facade.js").SquareApplication;
36
+ export declare function applicationFor(context: CommandContext): import("../square-facade.js").SquareApplication<true>;
37
37
  export declare function requireSquarePath(context: CommandContext): string;
package/dist/inbox.d.ts CHANGED
@@ -36,6 +36,8 @@ export type NativePendingPreviewInput = {
36
36
  export declare function nativePendingPreview(facts: SquareRead, input: NativePendingPreviewInput): NativePendingPreview | undefined;
37
37
  export declare function sessionInbox(sessionId: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<InboxMembership[]>;
38
38
  export interface SessionPendingObserver {
39
+ /** Read committed revisions before reusing content; filesystem hints are only for waiting. */
40
+ read(options?: Pick<PendingWaitOptions, 'signal' | 'excludeKeys'>): Promise<InboxMembership[]>;
39
41
  wait(timeoutMs: number, options?: PendingWaitOptions): Promise<InboxMembership[]>;
40
42
  close(): void;
41
43
  }
package/dist/inbox.js CHANGED
@@ -109,7 +109,66 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
109
109
  const ledger = await hostLedger.observeChanges();
110
110
  const artifacts = new Map();
111
111
  const lifetime = new AbortController();
112
+ const bindingsNow = async () => Promise.all((await projectSessionBindings({ hostLedger, sessionId })).map(async (binding) => ({ ...binding, location: await canonicalPath(binding.location) })));
113
+ async function membershipAt(binding, version, signal) {
114
+ const cache = artifacts.get(binding.location)?.inbox;
115
+ const name = nameKey(binding.participant);
116
+ const coordinate = bindingCoordinate(binding);
117
+ let cached = cache?.get(name);
118
+ if (cached === undefined || cached.version !== version || cached.binding !== coordinate) {
119
+ cached = { ...await readInboxMembership(binding, signal), binding: coordinate };
120
+ cache?.set(name, cached);
121
+ }
122
+ const membership = cached.value;
123
+ if (membership === undefined)
124
+ return undefined;
125
+ const { catchLease, ...rest } = membership;
126
+ const now = Date.now();
127
+ return catchLease !== undefined && catchLease.expiresAt > now && now - catchLease.heartbeatAt <= WATCH_STALE_MS
128
+ ? membership : rest;
129
+ }
130
+ async function eligibleInbox(inbox, excludeKeys) {
131
+ const eligible = await projectBoundaryEligibility({ hostLedger, sessionId, inbox });
132
+ return excludeKeys === undefined ? eligible : eligible.flatMap((membership) => {
133
+ const notifications = membership.notifications.filter((notification) => !excludeKeys.has(notificationKey(membership, notification.actIndex)));
134
+ return notifications.length === 0 ? [] : [{ ...membership, notifications }];
135
+ });
136
+ }
112
137
  return {
138
+ async read(options = {}) {
139
+ const signal = AbortSignal.any([lifetime.signal, ...(options.signal === undefined ? [] : [options.signal])]);
140
+ signal.throwIfAborted();
141
+ const bindings = await bindingsNow();
142
+ const revisions = new Map();
143
+ const inbox = [];
144
+ for (const binding of bindings) {
145
+ signal.throwIfAborted();
146
+ try {
147
+ let version = revisions.get(binding.location);
148
+ if (version === undefined) {
149
+ const artifact = createArtifactPort(binding.location);
150
+ try {
151
+ version = await artifact.revision(signal);
152
+ }
153
+ finally {
154
+ await artifact.close();
155
+ }
156
+ revisions.set(binding.location, version);
157
+ }
158
+ const membership = await membershipAt(binding, version, signal);
159
+ if (membership !== undefined)
160
+ inbox.push(membership);
161
+ }
162
+ catch (error) {
163
+ if (signal.aborted)
164
+ throw signal.reason ?? error;
165
+ // An unreadable current revision must never authorize cached content.
166
+ }
167
+ }
168
+ const pending = await eligibleInbox(inbox, options.excludeKeys);
169
+ signal.throwIfAborted();
170
+ return pending;
171
+ },
113
172
  async wait(timeoutMs, options = {}) {
114
173
  const deadline = Date.now() + Math.max(0, timeoutMs);
115
174
  const expired = new AbortController();
@@ -123,7 +182,7 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
123
182
  while (!signal.aborted) {
124
183
  // Subscribe/capture the ledger BEFORE discovering bindings; a concurrent join cannot fall in a gap.
125
184
  const sources = await Promise.all([capture(ledger.presence, 'presence', signal), capture(ledger.evidence, 'evidence', signal)]);
126
- const bindings = await Promise.all((await projectSessionBindings({ hostLedger, sessionId })).map(async (binding) => ({ ...binding, location: await canonicalPath(binding.location) })));
185
+ const bindings = await bindingsNow();
127
186
  const paths = new Set(bindings.map((binding) => binding.location));
128
187
  for (const [file, entry] of artifacts) {
129
188
  if (!paths.has(file)) {
@@ -155,22 +214,10 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
155
214
  for (const binding of bindings) {
156
215
  if (signal.aborted)
157
216
  return [];
158
- const entry = artifacts.get(binding.location);
159
- const name = nameKey(binding.participant);
160
217
  try {
161
- const coordinate = bindingCoordinate(binding);
162
- const cached = entry.inbox.get(name);
163
- if (cached?.version !== revisions.get(binding.location) || cached?.binding !== coordinate) {
164
- const projection = await readInboxMembership(binding, signal);
165
- entry.inbox.set(name, { ...projection, binding: coordinate });
166
- }
167
- const membership = entry.inbox.get(name)?.value;
168
- if (membership === undefined)
169
- continue;
170
- const { catchLease, ...rest } = membership;
171
- const now = Date.now();
172
- inbox.push(catchLease !== undefined && catchLease.expiresAt > now && now - catchLease.heartbeatAt <= WATCH_STALE_MS
173
- ? membership : rest);
218
+ const membership = await membershipAt(binding, revisions.get(binding.location), signal);
219
+ if (membership !== undefined)
220
+ inbox.push(membership);
174
221
  }
175
222
  catch (error) {
176
223
  if (signal.aborted)
@@ -178,12 +225,7 @@ export async function observeSessionPending(sessionId, suppliedEnv = process.env
178
225
  // Unreadable projections are retried, never cached as current.
179
226
  }
180
227
  }
181
- const eligible = await projectBoundaryEligibility({ hostLedger, sessionId, inbox });
182
- const excludeKeys = options.excludeKeys;
183
- const pending = excludeKeys === undefined ? eligible : eligible.flatMap((membership) => {
184
- const notifications = membership.notifications.filter((notification) => !excludeKeys.has(notificationKey(membership, notification.actIndex)));
185
- return notifications.length === 0 ? [] : [{ ...membership, notifications }];
186
- });
228
+ const pending = await eligibleInbox(inbox, options.excludeKeys);
187
229
  const eligibleKeys = JSON.stringify(pending.flatMap((membership) => membership.notifications.map((note) => notificationKey(membership, note.actIndex))).sort());
188
230
  if (previousEligible !== undefined && previousEligible !== eligibleKeys)
189
231
  canReturn = true;
package/dist/mcp.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { SquareApplicationContext } from './square-facade.js';
3
- export interface SquareMcpOptions extends SquareApplicationContext {
3
+ /** The server owns its artifact connection lifetime, so callers cannot lend one. */
4
+ export interface SquareMcpOptions extends Omit<SquareApplicationContext, 'resources'> {
4
5
  }
5
6
  export type SquareMcpServer = McpServer;
6
7
  export declare function createSquareMcpServer(options: SquareMcpOptions): SquareMcpServer;
package/dist/mcp.js CHANGED
@@ -4,7 +4,7 @@ import { z } from 'zod/v4';
4
4
  import { SquareError, isSquareError } from './model.js';
5
5
  import { operationEnv } from './automatic-session.js';
6
6
  import { SQUARE_IDENTITY } from './identity.js';
7
- import { createSquareApplication } from './square-application.js';
7
+ import { createSquareApplication, createSquareResourceOwner } from './square-application.js';
8
8
  const activityId = z.string().regex(/^act\/(0|[1-9][0-9]*)$/);
9
9
  const stringArray = z.array(z.string());
10
10
  const participantName = z.string().optional().describe("Participant name, same as --as. Defaults to the server's configured participant.");
@@ -44,37 +44,62 @@ function operation(app, name, args, control) {
44
44
  }
45
45
  export function createSquareMcpServer(options) {
46
46
  const baseEnv = { ...options.env };
47
+ const resources = createSquareResourceOwner();
48
+ // One explicit lifetime for this server: every tool call borrows the same connection.
49
+ const inFlight = new Set();
47
50
  const server = new McpServer({ name: 'square', version: SQUARE_IDENTITY.packageVersion }, {
48
51
  instructions: 'The catch tool consumes activities. Use history with act/<index> cursors for read-only archive access.',
49
52
  });
50
53
  for (const tool of TOOL_DEFINITIONS) {
51
- server.registerTool(tool.name, { description: tool.description, inputSchema: tool.inputSchema }, async (args, extra) => {
52
- try {
53
- const { as: participant, ...operationArgs } = args;
54
- const threadId = extra._meta?.threadId;
55
- const opencodeSessionId = extra._meta?.['ai.opencode/sessionID'];
56
- const env = typeof threadId === 'string' && threadId.trim()
57
- ? operationEnv('codex', threadId, baseEnv)
58
- : typeof opencodeSessionId === 'string' && opencodeSessionId.trim()
59
- ? operationEnv('opencode', opencodeSessionId, baseEnv)
60
- : baseEnv;
61
- const app = createSquareApplication({ ...options, env, participant: participant ?? options.participant });
62
- const outcome = await operation(app, tool.name, operationArgs, { signal: extra.signal });
63
- const structuredContent = outcome.data;
64
- return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent };
65
- }
66
- catch (error) {
67
- const normalized = extra.signal.aborted
68
- ? { code: 'cancelled', message: 'The MCP request was cancelled' }
69
- : isSquareError(error)
70
- ? { code: error.code, message: error.message, ...(error.facts === undefined ? {} : { facts: error.facts }) }
71
- : { code: 'internal', message: error instanceof Error ? error.message : String(error) };
72
- const operation = error instanceof Error ? error.operation : undefined;
73
- const failure = operation === undefined ? normalized : { ...normalized, operation };
74
- return { isError: true, content: [{ type: 'text', text: JSON.stringify(failure) }], structuredContent: { error: failure } };
75
- }
54
+ server.registerTool(tool.name, { description: tool.description, inputSchema: tool.inputSchema }, (args, extra) => {
55
+ const call = (async () => {
56
+ try {
57
+ const { as: participant, ...operationArgs } = args;
58
+ const threadId = extra._meta?.threadId;
59
+ const opencodeSessionId = extra._meta?.['ai.opencode/sessionID'];
60
+ const env = typeof threadId === 'string' && threadId.trim()
61
+ ? operationEnv('codex', threadId, baseEnv)
62
+ : typeof opencodeSessionId === 'string' && opencodeSessionId.trim()
63
+ ? operationEnv('opencode', opencodeSessionId, baseEnv)
64
+ : baseEnv;
65
+ const app = createSquareApplication({ ...options, env, participant: participant ?? options.participant, resources }, { presentation: false });
66
+ const outcome = await operation(app, tool.name, operationArgs, { signal: extra.signal });
67
+ const structuredContent = outcome.data;
68
+ return { content: [{ type: 'text', text: JSON.stringify(structuredContent) }], structuredContent };
69
+ }
70
+ catch (error) {
71
+ const normalized = extra.signal.aborted
72
+ ? { code: 'cancelled', message: 'The MCP request was cancelled' }
73
+ : isSquareError(error)
74
+ ? { code: error.code, message: error.message, ...(error.facts === undefined ? {} : { facts: error.facts }) }
75
+ : { code: 'internal', message: error instanceof Error ? error.message : String(error) };
76
+ const operation = error instanceof Error ? error.operation : undefined;
77
+ const failure = operation === undefined ? normalized : { ...normalized, operation };
78
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(failure) }], structuredContent: { error: failure } };
79
+ }
80
+ })();
81
+ inFlight.add(call);
82
+ return call.finally(() => { inFlight.delete(call); });
76
83
  });
77
84
  }
85
+ const close = server.close.bind(server);
86
+ // One idempotent shutdown for both close paths: an explicit server.close() and a
87
+ // transport-initiated disconnect, where the SDK protocol fires onclose by itself.
88
+ let draining;
89
+ const drain = () => draining ??= (async () => {
90
+ // The SDK aborts in-flight request signals before onclose/close returns; wait for
91
+ // those handlers to release the shared connection (their cleanup writes included).
92
+ await Promise.allSettled([...inFlight]);
93
+ await resources.close();
94
+ })();
95
+ server.close = async () => { await close(); await drain(); };
96
+ const priorOnclose = server.server.onclose;
97
+ server.server.onclose = () => {
98
+ // onclose is synchronous, so the disconnect path starts the drain without awaiting it;
99
+ // the handler's own catch keeps an unexpected close failure from crashing the process.
100
+ void drain().catch(() => undefined);
101
+ priorOnclose?.();
102
+ };
78
103
  return server;
79
104
  }
80
105
  export async function runSquareMcpStdio(options) {
package/dist/model.js CHANGED
@@ -37,11 +37,14 @@ export function nameKey(name) {
37
37
  export function sameName(a, b) {
38
38
  return nameKey(a) === nameKey(b);
39
39
  }
40
+ const TEXT_NAME_GRAPHEME_SOURCE = String.raw `[[\p{L}\p{N}\p{M}_\x2D]--[\p{Variation_Selector}]]`;
40
41
  /** One name grapheme: Unicode letters, digits, marks, hyphens, or underscores (no isolated variation selectors), or a complete RGI emoji. Shared by name validation and body @mention scanning. */
41
- export const NAME_GRAPHEME_SOURCE = String.raw `[[\p{L}\p{N}\p{M}_\x2D]--[\p{Variation_Selector}]]|\p{RGI_Emoji}`;
42
+ export const NAME_GRAPHEME_SOURCE = String.raw `${TEXT_NAME_GRAPHEME_SOURCE}|\p{RGI_Emoji}`;
43
+ // Ordinary Unicode names need not trigger the full RGI emoji matcher’s cold compilation.
44
+ const TEXT_NAME_PATTERN = new RegExp(`^(?:${TEXT_NAME_GRAPHEME_SOURCE})+(?:/(?:${TEXT_NAME_GRAPHEME_SOURCE})+)*$`, 'v');
42
45
  const NAME_PATTERN = new RegExp(`^(?:${NAME_GRAPHEME_SOURCE})+(?:/(?:${NAME_GRAPHEME_SOURCE})+)*$`, 'v');
43
46
  export function validateName(name) {
44
- if (!name || !NAME_PATTERN.test(name)) {
47
+ if (!name || (!TEXT_NAME_PATTERN.test(name) && !NAME_PATTERN.test(name))) {
45
48
  throw new SquareError('invalid_name', 'Invalid name: names must contain non-empty slash-separated segments using Unicode letters, digits, marks, hyphens, underscores, or complete RGI emoji graphemes.');
46
49
  }
47
50
  }
@@ -1,4 +1,4 @@
1
- import type { HostLedgerPort, SquareArtifactPort, WakeTransportPort } from './ports.js';
1
+ import type { HostLedgerPort, SquareArtifactPort, SquareResourceOwner, WakeTransportPort } from './ports.js';
2
2
  import type { OperationControl } from './square-facade.js';
3
3
  /** Host-bound resource: artifact, captured environment, clock, ledger and transport. */
4
4
  export interface OpenSquare {
@@ -8,6 +8,8 @@ export interface OpenSquare {
8
8
  readonly hostLedger?: HostLedgerPort;
9
9
  readonly wakeTransport?: WakeTransportPort;
10
10
  readonly env?: NodeJS.ProcessEnv;
11
+ /** False when a long-lived owner lent the connection; closing this view leaves it open. */
12
+ readonly ownsArtifact: boolean;
11
13
  }
12
14
  export interface SquareBuildOptions {
13
15
  markdown: string;
@@ -20,7 +22,15 @@ export interface SquareBuildOptions {
20
22
  /** One caller deadline propagated into artifact busy retries. */
21
23
  signal?: AbortSignal;
22
24
  }
23
- export declare function openSquare(squarePath: string, options?: Pick<SquareBuildOptions, 'clock' | 'hostLedger' | 'wakeTransport' | 'env' | 'signal'>, control?: OperationControl): Promise<OpenSquare>;
25
+ export interface SquareOpenOptions extends Pick<SquareBuildOptions, 'clock' | 'hostLedger' | 'wakeTransport' | 'env' | 'signal'> {
26
+ /** A caller-owned connection to reuse; this view never closes it. */
27
+ readonly artifact?: SquareArtifactPort;
28
+ }
29
+ export declare function openSquare(squarePath: string, options?: SquareOpenOptions, control?: OperationControl): Promise<OpenSquare>;
24
30
  export declare function buildSquare(squarePath: string, options: SquareBuildOptions): Promise<OpenSquare>;
25
31
  export declare function buildMemorySquare(options: SquareBuildOptions): OpenSquare;
26
32
  export declare function closeOpenSquare(square: OpenSquare): Promise<void>;
33
+ /** Explicit-lifetime holder of one reused artifact connection. The first request
34
+ * binds the location; a request for any other location is refused instead of
35
+ * silently sharing the wrong square. The owner closes the connection once. */
36
+ export declare function createSquareResourceOwner(): SquareResourceOwner;
@@ -1,20 +1,25 @@
1
+ import path from 'node:path';
1
2
  import { createArtifactPort, createSquare, prepareSquareBuild } from './artifact.js';
2
3
  import { InternalSquareError, SquareError } from './model.js';
3
4
  import { hostLedgerRoot } from './host-ledger-root.js';
4
5
  import { createHostLedgerPort } from './host-ledger-file-adapter.js';
5
6
  export async function openSquare(squarePath, options = {}, control) {
6
7
  const env = { ...(options.env ?? process.env) };
7
- const artifact = createArtifactPort(squarePath, undefined, options.signal);
8
+ const borrowed = options.artifact;
9
+ // A borrowed connection was opened by its owner without a request signal; this
10
+ // request may only abort its own read, never the shared connection's lifetime.
11
+ const artifact = borrowed ?? createArtifactPort(squarePath, undefined, options.signal);
8
12
  try {
9
13
  await artifact.read(facts => facts.header(), control?.signal);
10
14
  return {
11
- artifact, clock: options.clock ?? Date.now, location: squarePath, env,
15
+ artifact, clock: options.clock ?? Date.now, location: squarePath, env, ownsArtifact: borrowed === undefined,
12
16
  hostLedger: options.hostLedger ?? createHostLedgerPort({ rootPath: hostLedgerRoot(env) }),
13
17
  wakeTransport: options.wakeTransport,
14
18
  };
15
19
  }
16
20
  catch (error) {
17
- await artifact.close();
21
+ if (borrowed === undefined)
22
+ await artifact.close();
18
23
  if (error instanceof InternalSquareError && error.code === 'not_found')
19
24
  throw new SquareError('unavailable', `Square is unavailable at ${squarePath}`);
20
25
  throw error;
@@ -36,8 +41,41 @@ export function buildMemorySquare(options) {
36
41
  artifact: createArtifactPort(':memory:', prepareSquareBuild(options, options.markdown)),
37
42
  clock: options.clock ?? Date.now, location: 'memory',
38
43
  hostLedger: options.hostLedger, wakeTransport: options.wakeTransport,
44
+ ownsArtifact: true,
39
45
  };
40
46
  }
41
47
  export function closeOpenSquare(square) {
42
- return square.artifact.close();
48
+ // A borrowed connection belongs to its owner; only owned views close it here.
49
+ return square.ownsArtifact === false ? Promise.resolve() : square.artifact.close();
50
+ }
51
+ /** Explicit-lifetime holder of one reused artifact connection. The first request
52
+ * binds the location; a request for any other location is refused instead of
53
+ * silently sharing the wrong square. The owner closes the connection once. */
54
+ export function createSquareResourceOwner() {
55
+ let held;
56
+ let closed = false;
57
+ return {
58
+ artifact(location) {
59
+ if (closed)
60
+ throw new SquareError('unavailable', 'Square resource owner is closed');
61
+ const key = path.resolve(location);
62
+ if (held === undefined) {
63
+ // No external signal: a request must never become the shared connection's lifetime.
64
+ held = { location: key, artifact: createArtifactPort(key) };
65
+ }
66
+ if (held.location !== key) {
67
+ throw new SquareError('unavailable', `Square resource owner already holds ${held.location}; it cannot also open ${key}`);
68
+ }
69
+ return held.artifact;
70
+ },
71
+ async close() {
72
+ if (closed)
73
+ return;
74
+ closed = true;
75
+ const artifact = held?.artifact;
76
+ held = undefined;
77
+ if (artifact !== undefined)
78
+ await artifact.close();
79
+ },
80
+ };
43
81
  }
@@ -11,8 +11,12 @@ export async function identityRouteDraft(context, participant) {
11
11
  return undefined;
12
12
  const identity = processIdentity(context.env ?? process.env);
13
13
  const provider = identity.channel === 'claude-code' ? 'claude' : identity.channel === 'opencode' ? 'opencode' : identity.channel === 'pi' ? 'pi' : identity.channel === 'paseo' ? 'paseo' : 'codex';
14
- const capabilities = context.hostLedger === undefined ? { canUse: () => false } : await defaultWakeRouteCapabilities(context.hostLedger);
15
- return resolvePrimaryWakeRoute({ location: context.location, participant, sessionId: identity.session, provider }, context.env ?? process.env, capabilities);
14
+ const boundary = { location: context.location, participant, sessionId: identity.session, provider };
15
+ const env = context.env ?? process.env;
16
+ // No adapter can add a route when the host has no candidate in the first place.
17
+ if (context.hostLedger === undefined || resolvePrimaryWakeRoute(boundary, env, { canUse: () => true }) === undefined)
18
+ return undefined;
19
+ return resolvePrimaryWakeRoute(boundary, env, await defaultWakeRouteCapabilities(context.hostLedger));
16
20
  }
17
21
  /** One postcommit host observation; presence and route publication fail independently. */
18
22
  export async function publishCommittedIdentity(context, participant, epoch) {
package/dist/ports.d.ts CHANGED
@@ -165,6 +165,16 @@ export interface SquareArtifactPort {
165
165
  changed(sinceVersion: number, timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
166
166
  close(): Promise<void>;
167
167
  }
168
+ /** Explicit lifetime for an artifact connection reused by a long-lived caller.
169
+ * Only the connection is shared: every open still derives its own environment,
170
+ * clock, ledger and transport, and no request signal becomes a connection lifetime.
171
+ * The owner holds one location and refuses a second rather than sharing the wrong
172
+ * square; the caller lends the connection and never closes it. */
173
+ export interface SquareResourceOwner {
174
+ /** The shared connection for a location. Never close it; the owner does. */
175
+ artifact(location: string): SquareArtifactPort;
176
+ close(): Promise<void>;
177
+ }
168
178
  /** Capability-neutral wake transport. Unused by this Contract's activity operations. */
169
179
  export interface WakeTransportPort {
170
180
  /** Capability check that performs no external wake and writes no evidence. */
@@ -8,7 +8,9 @@ export interface OperationContext extends HostContext {
8
8
  export type { OwnershipFenceOptions };
9
9
  export declare function catchUp(square: OperationContext, name: string, options?: CatchOptions, control?: OperationControl): Promise<CatchSelection>;
10
10
  export declare function catchUp(square: OperationContext, name: string, options: CatchOptions, control: OperationControl | undefined, leaseId: string): Promise<CatchSelection | null>;
11
- export declare function join(square: OperationContext, name: string, control?: OperationControl): Promise<{
11
+ export declare function join(square: OperationContext, name: string, control?: OperationControl, admission?: {
12
+ readonly requireKnown?: boolean;
13
+ }): Promise<{
12
14
  readonly name: string;
13
15
  readonly activity: Activity | null;
14
16
  readonly participantCount: number;
@@ -33,14 +33,19 @@ export async function catchUp(square, name, options = {}, control, leaseId) {
33
33
  await publishCommittedIdentity(square, selected.participant);
34
34
  return selected;
35
35
  }
36
- export async function join(square, name, control) {
36
+ export async function join(square, name, control, admission = {}) {
37
37
  throwIfAborted(control);
38
38
  validateName(name);
39
39
  const hostLedger = square.hostLedger;
40
- const preview = (await square.artifact.read((facts) => ({
41
- ...decideJoin(facts, name, square.clock()),
42
- participantCount: facts.roster().filter((p) => p.joined || p.done).length,
43
- }), control?.signal)).value;
40
+ const preview = (await square.artifact.read((facts) => {
41
+ // Express may reenter a known participant, but must never create a new name.
42
+ const selected = admission.requireKnown ? resolveKnownName(facts, name) : name;
43
+ return {
44
+ ...decideJoin(facts, selected, square.clock()),
45
+ participantCount: facts.roster().filter((p) => p.joined || p.done).length,
46
+ };
47
+ }, control?.signal)).value;
48
+ name = preview.joinedName;
44
49
  if (preview.joinAct === undefined) {
45
50
  if (hostLedger === undefined)
46
51
  return { name: preview.joinedName, activity: null, participantCount: preview.participantCount, reconnected: false };
@@ -1,5 +1,6 @@
1
1
  import type { Activity, ExpressResult, HistoryQuery, OperationControl, Participant, ParticipantStatus, SquareApplication, SquareApplicationContext, SquareAtInput, SquareBuildInput, SquareSnapshot } from './square-facade.js';
2
2
  export type * from './square-facade.js';
3
+ export { createSquareResourceOwner } from './open-square.js';
3
4
  export { formatActivityId, parseActivityId, MAX_IDENTITY_SET_SIZE } from './square-core.js';
4
5
  export { CATCH_DEFAULT_LIMIT, CATCH_MAX_LIMIT } from './catch-decisions.js';
5
6
  export { HELD_WAIT_BUDGET_MS } from './runtime.js';
@@ -32,3 +33,6 @@ export declare class Square {
32
33
  retireEndedSessionRoutes(sessionId: string): Promise<void>;
33
34
  }
34
35
  export declare function createSquareApplication(context: SquareApplicationContext): SquareApplication;
36
+ export declare function createSquareApplication(context: SquareApplicationContext, demand: {
37
+ readonly presentation: false;
38
+ }): SquareApplication<false>;