@astrosheep/square 0.3.72 → 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.
- package/dist/artifact.d.ts +0 -3
- package/dist/artifact.js +5 -59
- package/dist/boundary-presentation.d.ts +8 -1
- package/dist/boundary-presentation.js +8 -1
- package/dist/catch-decisions.d.ts +7 -1
- package/dist/catch-decisions.js +21 -8
- package/dist/claude-delivery.d.ts +1 -1
- package/dist/claude-delivery.js +7 -3
- package/dist/cli/context.d.ts +1 -1
- package/dist/cli/ledger-command.d.ts +12 -0
- package/dist/cli/ledger-command.js +68 -0
- package/dist/cli/registry.js +2 -0
- package/dist/help.js +7 -2
- package/dist/host-ledger-file-adapter.d.ts +4 -10
- package/dist/host-ledger-file-adapter.js +238 -198
- package/dist/host-ledger-migration.d.ts +26 -0
- package/dist/host-ledger-migration.js +227 -0
- package/dist/host-ledger-sqlite.d.ts +62 -0
- package/dist/host-ledger-sqlite.js +341 -0
- package/dist/host-ledger.d.ts +13 -4
- package/dist/inbox.d.ts +2 -0
- package/dist/inbox.js +64 -22
- package/dist/ledger-maintenance.d.ts +30 -0
- package/dist/ledger-maintenance.js +110 -0
- package/dist/mcp.d.ts +2 -1
- package/dist/mcp.js +51 -26
- package/dist/model.js +5 -2
- package/dist/open-square.d.ts +12 -2
- package/dist/open-square.js +42 -4
- package/dist/participant-host.js +6 -2
- package/dist/ports.d.ts +17 -1
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.js +12 -6
- package/dist/sqlite-transaction.d.ts +5 -0
- package/dist/sqlite-transaction.js +72 -0
- package/dist/square-actions.d.ts +3 -1
- package/dist/square-actions.js +26 -13
- package/dist/square-application.d.ts +4 -0
- package/dist/square-application.js +41 -27
- package/dist/square-facade.d.ts +26 -22
- package/dist/square-migration.js +2 -1
- package/dist/square-sqlite.d.ts +2 -2
- package/dist/square-sqlite.js +15 -4
- package/dist/views.d.ts +3 -1
- package/dist/views.js +37 -29
- package/guides/application-demand-optimization.md +50 -0
- package/guides/catch-cold-path-optimization.md +56 -0
- package/guides/catch-contention-performance.md +157 -0
- package/guides/catch-lock-investigation.md +81 -0
- package/guides/change-waits.md +1 -1
- package/guides/host-ledger.md +53 -6
- package/guides/indexed-host-ledger-performance.md +39 -0
- package/guides/mcp-connection-lifetime.md +22 -0
- package/guides/pi-boundary-pending-reuse.md +13 -0
- package/package.json +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/.codex-plugin/plugin.json +1 -1
- package/plugins/paseo/client/square-message.tsx +35 -19
- package/plugins/pi/square-pi.js +4 -8
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
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
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;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { HostLedgerPort } from './host-ledger.js';
|
|
2
|
+
/** The two host-ledger capabilities maintenance uses; no parallel ledger contract is owned here. */
|
|
3
|
+
type MaintenanceLedger = Pick<HostLedgerPort, 'listEvidence' | 'retireEvidenceIfUnchanged'>;
|
|
4
|
+
export interface LedgerMaintenanceOptions {
|
|
5
|
+
/** Prove eligible rows without retiring anything. */
|
|
6
|
+
readonly dryRun?: boolean;
|
|
7
|
+
readonly now?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Counts are retired rows, not reclaimed bytes: SQLite reuses freed storage without necessarily
|
|
11
|
+
* shrinking its file. Retired Claude correlations leave indefinite recognition tokens; total
|
|
12
|
+
* token count remains unbounded. Maintenance does not run VACUUM.
|
|
13
|
+
*/
|
|
14
|
+
export interface LedgerMaintenanceReport {
|
|
15
|
+
readonly dryRun: boolean;
|
|
16
|
+
/** Old rows whose closure proof passed; exact-record CAS may still refuse. */
|
|
17
|
+
readonly eligible: number;
|
|
18
|
+
/** Rows actually removed by this run; always 0 in a dry run. */
|
|
19
|
+
readonly retired: number;
|
|
20
|
+
/** Eligible rows whose full-record compare-and-swap refused; always 0 in a dry run. */
|
|
21
|
+
readonly casMisses: number;
|
|
22
|
+
/** Canonical locations whose artifact could not be read; their rows were retained. */
|
|
23
|
+
readonly unavailableArtifacts: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
/** Command/application entry: root selection uses the caller's captured environment. */
|
|
26
|
+
export declare function gcHostLedger(options?: LedgerMaintenanceOptions & {
|
|
27
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
28
|
+
}): Promise<LedgerMaintenanceReport>;
|
|
29
|
+
export declare function maintainHostLedger(ledger: MaintenanceLedger, options?: LedgerMaintenanceOptions): Promise<LedgerMaintenanceReport>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createArtifactPort } from './artifact.js';
|
|
2
|
+
import { canonicalPath } from './canonical-path.js';
|
|
3
|
+
import { formatActivityId, parseActivityId } from './square-core.js';
|
|
4
|
+
import { validateName } from './model.js';
|
|
5
|
+
import { hostLedgerForEnv } from './registry.js';
|
|
6
|
+
/**
|
|
7
|
+
* Explicit host-ledger evidence maintenance.
|
|
8
|
+
*
|
|
9
|
+
* Old wake/presentation rows are retired only when a typed artifact snapshot proves the
|
|
10
|
+
* recipient's attention is permanently closed: the activity reached them historically, and
|
|
11
|
+
* either catch recorded a receipt or the recipient joined again past it. Everything else is
|
|
12
|
+
* retained. Artifacts are read directly, one snapshot per canonical location, outside any
|
|
13
|
+
* ledger lock; retirement is the ledger's exact-record compare-and-swap. No SQL lives here.
|
|
14
|
+
*/
|
|
15
|
+
/** A row is old enough to consider once its landing time is at least this far behind now. */
|
|
16
|
+
const RETIREMENT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
/** The canonical activity coordinate the row names; leading zeros and other spellings are invalid. */
|
|
18
|
+
function activityIndex(activity) {
|
|
19
|
+
const index = parseActivityId(activity);
|
|
20
|
+
return index === undefined || formatActivityId(index) !== activity ? undefined : index;
|
|
21
|
+
}
|
|
22
|
+
/** Old, durably closed-looking wake/presentation rows with usable coordinates. */
|
|
23
|
+
function isCandidate(record, now) {
|
|
24
|
+
if (record.kind !== 'wake' && record.kind !== 'presentation')
|
|
25
|
+
return false;
|
|
26
|
+
if (record.outcome === 'dispatching')
|
|
27
|
+
return false;
|
|
28
|
+
if (typeof record.at !== 'number' || !Number.isFinite(record.at) || record.at > now - RETIREMENT_AGE_MS)
|
|
29
|
+
return false;
|
|
30
|
+
if (typeof record.location !== 'string' || record.location.length === 0)
|
|
31
|
+
return false;
|
|
32
|
+
try {
|
|
33
|
+
validateName(record.participant);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One artifact snapshot for one location either proves each candidate closed or reports the
|
|
42
|
+
* whole location unavailable. A missing, unreadable, or non-artifact path returns undefined
|
|
43
|
+
* and its rows are retained.
|
|
44
|
+
*/
|
|
45
|
+
async function provenClosable(location, group) {
|
|
46
|
+
const artifact = createArtifactPort(location);
|
|
47
|
+
try {
|
|
48
|
+
return (await artifact.read((facts) => group.filter((candidate) => {
|
|
49
|
+
const participant = candidate.record.participant;
|
|
50
|
+
if (facts.directedAt(participant, candidate.index) === undefined)
|
|
51
|
+
return false;
|
|
52
|
+
if (facts.receipt(participant, candidate.index) !== undefined)
|
|
53
|
+
return true;
|
|
54
|
+
const lastJoin = facts.participant(participant)?.lastJoinIndex;
|
|
55
|
+
return lastJoin !== undefined && lastJoin > candidate.index;
|
|
56
|
+
}))).value;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await artifact.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Command/application entry: root selection uses the caller's captured environment. */
|
|
66
|
+
export function gcHostLedger(options = {}) {
|
|
67
|
+
return maintainHostLedger(hostLedgerForEnv(options.env ?? process.env), options);
|
|
68
|
+
}
|
|
69
|
+
export async function maintainHostLedger(ledger, options = {}) {
|
|
70
|
+
const dryRun = options.dryRun === true;
|
|
71
|
+
const now = options.now ?? Date.now();
|
|
72
|
+
const records = await ledger.listEvidence({ includeReleased: true, includeExpired: true, now });
|
|
73
|
+
const groups = new Map();
|
|
74
|
+
for (const record of records) {
|
|
75
|
+
if (!isCandidate(record, now))
|
|
76
|
+
continue;
|
|
77
|
+
const index = activityIndex(record.activity);
|
|
78
|
+
if (index === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
const location = await canonicalPath(record.location).catch(() => undefined);
|
|
81
|
+
if (location === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
const group = groups.get(location);
|
|
84
|
+
if (group === undefined)
|
|
85
|
+
groups.set(location, [{ record, index }]);
|
|
86
|
+
else
|
|
87
|
+
group.push({ record, index });
|
|
88
|
+
}
|
|
89
|
+
let eligible = 0;
|
|
90
|
+
let retired = 0;
|
|
91
|
+
let casMisses = 0;
|
|
92
|
+
const unavailable = new Set();
|
|
93
|
+
for (const location of [...groups.keys()].sort()) {
|
|
94
|
+
const closable = await provenClosable(location, groups.get(location));
|
|
95
|
+
if (closable === undefined) {
|
|
96
|
+
unavailable.add(location);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
eligible += closable.length;
|
|
100
|
+
if (dryRun)
|
|
101
|
+
continue;
|
|
102
|
+
for (const candidate of closable) {
|
|
103
|
+
if (await ledger.retireEvidenceIfUnchanged(candidate.record))
|
|
104
|
+
retired += 1;
|
|
105
|
+
else
|
|
106
|
+
casMisses += 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { dryRun, eligible, retired, casMisses, unavailableArtifacts: [...unavailable].sort() };
|
|
110
|
+
}
|
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
|
-
|
|
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 },
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
}
|
package/dist/open-square.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/open-square.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/participant-host.js
CHANGED
|
@@ -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
|
|
15
|
-
|
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Act, HardCap, InboxNotification, StoredAct, WakeRoute, WakeRouteKind, WatchLease } from './model.js';
|
|
2
|
-
export type { ClaimResult, EvidenceClaim, EvidenceRelease,
|
|
2
|
+
export type { ClaimResult, EvidenceClaim, EvidenceRelease, EvidenceLookup, EvidenceRecord, NativeTokenLookup, NativeTokenResult, HostLedgerPort, PresenceKey, PresenceLookup, PresenceRecord, PresenceClaimResult, PresenceChannel, PresenceResult, } from './host-ledger.js';
|
|
3
3
|
import type { HostLedgerPort } from './host-ledger.js';
|
|
4
4
|
export type ActivityIndex = number;
|
|
5
5
|
export interface SquareHeader {
|
|
@@ -20,6 +20,11 @@ export interface ParticipantFact {
|
|
|
20
20
|
readonly lastActiveAt?: number;
|
|
21
21
|
readonly lastJoinIndex?: number;
|
|
22
22
|
}
|
|
23
|
+
export interface ParticipantCounts {
|
|
24
|
+
readonly known: number;
|
|
25
|
+
readonly joined: number;
|
|
26
|
+
readonly done: number;
|
|
27
|
+
}
|
|
23
28
|
export interface AttentionFact {
|
|
24
29
|
readonly listener: string;
|
|
25
30
|
readonly target: string;
|
|
@@ -98,6 +103,7 @@ export interface RouteFact {
|
|
|
98
103
|
export interface SquareRead {
|
|
99
104
|
header(): SquareHeader;
|
|
100
105
|
participant(requestedName: string): ParticipantFact | undefined;
|
|
106
|
+
participantCounts(): ParticipantCounts;
|
|
101
107
|
roster(): readonly ParticipantFact[];
|
|
102
108
|
attention(listener: string): readonly AttentionFact[];
|
|
103
109
|
incomingAttention(sender: string): readonly AttentionFact[];
|
|
@@ -159,6 +165,16 @@ export interface SquareArtifactPort {
|
|
|
159
165
|
changed(sinceVersion: number, timeoutMs: number, signal?: AbortSignal): Promise<boolean>;
|
|
160
166
|
close(): Promise<void>;
|
|
161
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
|
+
}
|
|
162
178
|
/** Capability-neutral wake transport. Unused by this Contract's activity operations. */
|
|
163
179
|
export interface WakeTransportPort {
|
|
164
180
|
/** Capability check that performs no external wake and writes no evidence. */
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ActivityId } from './square-core.js';
|
|
2
2
|
import { type StoredAct, type WatchLease } from './model.js';
|
|
3
|
-
import type { SquareRead } from './ports.js';
|
|
3
|
+
import type { ParticipantCounts, ParticipantFact, SquareRead } from './ports.js';
|
|
4
4
|
export declare const WATCH_HEARTBEAT_MS: number;
|
|
5
5
|
export declare const SLEEP_MS: number;
|
|
6
6
|
export declare const STALE_MS: number;
|
|
@@ -13,6 +13,7 @@ export declare function nowMs(): number;
|
|
|
13
13
|
export declare function rosterNames(facts: SquareRead): string[];
|
|
14
14
|
export declare function inSquareCount(facts: SquareRead): number;
|
|
15
15
|
export declare function actId(actOrIndex: StoredAct | number): ActivityId;
|
|
16
|
+
export declare function terminalStatusFor(current: ParticipantFact | undefined, hardCap: number | null, counts: ParticipantCounts): 'capped' | 'quorum' | undefined;
|
|
16
17
|
export declare function watchTerminalStatus(facts: SquareRead, name: string): 'capped' | 'quorum' | undefined;
|
|
17
18
|
/** A continuous prefix stops at the first unseen historical audience edge after join. */
|
|
18
19
|
export declare function readCursor(facts: SquareRead, name: string): number;
|
package/dist/runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatActivityId } from './square-core.js';
|
|
2
|
-
import { SquareError
|
|
2
|
+
import { SquareError } from './model.js';
|
|
3
3
|
function parseIntegerEnv(name, fallback) {
|
|
4
4
|
const raw = process.env[name];
|
|
5
5
|
if (raw === undefined)
|
|
@@ -27,14 +27,20 @@ export function nowMs() {
|
|
|
27
27
|
return value;
|
|
28
28
|
}
|
|
29
29
|
export function rosterNames(facts) { return facts.roster().map((participant) => participant.name); }
|
|
30
|
-
export function inSquareCount(facts) { return facts.
|
|
30
|
+
export function inSquareCount(facts) { return facts.participantCounts().joined; }
|
|
31
31
|
export function actId(actOrIndex) { return formatActivityId(typeof actOrIndex === 'number' ? actOrIndex : actOrIndex.index); }
|
|
32
|
+
export function terminalStatusFor(current, hardCap, counts) {
|
|
33
|
+
if (hardCap !== null && (current?.sayCount ?? 0) >= hardCap)
|
|
34
|
+
return 'capped';
|
|
35
|
+
const peers = counts.known - Number(current !== undefined);
|
|
36
|
+
const donePeers = counts.done - Number(current?.done === true);
|
|
37
|
+
return peers > 0 && donePeers === peers ? 'quorum' : undefined;
|
|
38
|
+
}
|
|
32
39
|
export function watchTerminalStatus(facts, name) {
|
|
33
|
-
const cap = facts.header().hardCap;
|
|
34
|
-
if (cap !== null && (
|
|
40
|
+
const cap = facts.header().hardCap, current = facts.participant(name);
|
|
41
|
+
if (cap !== null && (current?.sayCount ?? 0) >= cap)
|
|
35
42
|
return 'capped';
|
|
36
|
-
|
|
37
|
-
return peers.length > 0 && peers.every((peer) => peer.done) ? 'quorum' : undefined;
|
|
43
|
+
return terminalStatusFor(current, cap, facts.participantCounts());
|
|
38
44
|
}
|
|
39
45
|
/** A continuous prefix stops at the first unseen historical audience edge after join. */
|
|
40
46
|
export function readCursor(facts, name) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
/** Yield rather than blocking the process while another connection owns SQLite. */
|
|
3
|
+
export declare function retrySqliteBusy<T>(operation: () => T, signal?: AbortSignal, retryMs?: number | (() => number)): Promise<T>;
|
|
4
|
+
/** Acquisition may retry; COMMIT retries retain the callback's result and transaction. */
|
|
5
|
+
export declare function inSqliteTransaction<T, V>(db: DatabaseSync, write: boolean, validate: () => V, change: (validated: V) => T, signal?: AbortSignal): Promise<T>;
|