@astrosheep/square 0.3.90 → 0.3.92

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 (45) hide show
  1. package/dist/cli/context.js +2 -1
  2. package/dist/cli/maintenance-commands.js +5 -2
  3. package/dist/codex-address.d.ts +3 -0
  4. package/dist/codex-address.js +8 -0
  5. package/dist/codex-boundary-state.d.ts +6 -8
  6. package/dist/codex-boundary-state.js +8 -85
  7. package/dist/codex-hook.d.ts +1 -3
  8. package/dist/codex-hook.js +20 -76
  9. package/dist/codex-queue.d.ts +5 -17
  10. package/dist/codex-queue.js +21 -64
  11. package/dist/file-lock.d.ts +1 -17
  12. package/dist/file-lock.js +1 -64
  13. package/dist/help.js +2 -2
  14. package/dist/mcp.js +2 -1
  15. package/dist/packages/agent-delivery/src/codex-boundary.d.ts +13 -0
  16. package/dist/packages/agent-delivery/src/codex-boundary.js +75 -0
  17. package/dist/packages/agent-delivery/src/codex-cli.d.ts +10 -0
  18. package/dist/packages/agent-delivery/src/codex-cli.js +69 -0
  19. package/dist/packages/agent-delivery/src/codex-endpoint.d.ts +3 -0
  20. package/dist/packages/agent-delivery/src/codex-endpoint.js +14 -0
  21. package/dist/packages/agent-delivery/src/codex-hook.d.ts +26 -0
  22. package/dist/packages/agent-delivery/src/codex-hook.js +58 -0
  23. package/dist/packages/agent-delivery/src/codex-queue.d.ts +24 -0
  24. package/dist/packages/agent-delivery/src/codex-queue.js +58 -0
  25. package/dist/packages/agent-delivery/src/codex.d.ts +7 -0
  26. package/dist/packages/agent-delivery/src/codex.js +332 -0
  27. package/dist/packages/agent-delivery/src/errors.d.ts +1 -1
  28. package/dist/packages/agent-delivery/src/errors.js +1 -0
  29. package/dist/packages/agent-delivery/src/file-lock.d.ts +17 -0
  30. package/dist/packages/agent-delivery/src/file-lock.js +64 -0
  31. package/dist/packages/agent-delivery/src/index.d.ts +17 -3
  32. package/dist/packages/agent-delivery/src/index.js +29 -4
  33. package/dist/packages/agent-delivery/src/types.d.ts +58 -3
  34. package/dist/square-migration.d.ts +4 -0
  35. package/dist/square-migration.js +62 -51
  36. package/guides/codex-delivery.md +79 -0
  37. package/guides/migration.md +53 -27
  38. package/package.json +1 -1
  39. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  40. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  41. package/plugins/codex/hooks/hooks.json +3 -0
  42. package/plugins/paseo/client/markdown.tsx +16 -38
  43. package/plugins/paseo/client/palette.ts +62 -0
  44. package/plugins/paseo/client/square-message.tsx +75 -45
  45. package/plugins/paseo/shared/message.ts +16 -1
@@ -0,0 +1,64 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ export class FileLockError extends Error {
6
+ code = 'not_a_database';
7
+ constructor(lockPath, cause) {
8
+ super(`File lock is not a database: ${lockPath}`);
9
+ this.name = 'FileLockError';
10
+ if (cause !== undefined)
11
+ this.cause = cause;
12
+ }
13
+ }
14
+ function isBusy(error) {
15
+ return error instanceof Error && /database is locked|SQLITE_BUSY/i.test(error.message);
16
+ }
17
+ function isNotADatabase(error) {
18
+ return error instanceof Error && /not a database|file is not a database/i.test(error.message);
19
+ }
20
+ export async function withFileLock(lockPath, options, fn) {
21
+ if (options.createParent !== false)
22
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
23
+ while (true) {
24
+ if (options.signal?.aborted)
25
+ throw options.signal.reason ?? new Error('File lock acquisition aborted');
26
+ let database;
27
+ try {
28
+ database = new DatabaseSync(lockPath);
29
+ database.exec(`PRAGMA busy_timeout = 0;
30
+ BEGIN EXCLUSIVE;
31
+ CREATE TABLE IF NOT EXISTS __agent_delivery_lock (id INTEGER PRIMARY KEY CHECK (id = 1));`);
32
+ }
33
+ catch (error) {
34
+ try {
35
+ database?.close();
36
+ }
37
+ catch { }
38
+ if (isBusy(error)) {
39
+ if (options.skipIfBusy)
40
+ return undefined;
41
+ await sleep(options.retryMs, undefined, { signal: options.signal });
42
+ continue;
43
+ }
44
+ if (isNotADatabase(error))
45
+ throw new FileLockError(lockPath, error);
46
+ throw error;
47
+ }
48
+ try {
49
+ const result = await fn();
50
+ database.exec('COMMIT;');
51
+ return result;
52
+ }
53
+ catch (error) {
54
+ try {
55
+ database.exec('ROLLBACK;');
56
+ }
57
+ catch { }
58
+ throw error;
59
+ }
60
+ finally {
61
+ database.close();
62
+ }
63
+ }
64
+ }
@@ -1,6 +1,6 @@
1
1
  import { DeliveryError } from './errors.js';
2
- import type { OpenCodeConnectOptions, ClaudeConnectOptions, PiConnectOptions, PaseoConnectOptions } from './types.js';
3
- export type { OpenCodeConnectOptions, ClaudeConnectOptions, PiConnectOptions, PaseoConnectOptions } from './types.js';
2
+ import type { OpenCodeConnectOptions, ClaudeConnectOptions, PiConnectOptions, PaseoConnectOptions, CodexConnectOptions, CodexAppServerConnectOptions, CodexCliConnectOptions } from './types.js';
3
+ export type { OpenCodeConnectOptions, ClaudeConnectOptions, PiConnectOptions, PaseoConnectOptions, CodexConnectOptions, CodexAppServerConnectOptions, CodexCliConnectOptions } from './types.js';
4
4
  export interface SendOptions {
5
5
  readonly timeoutMs?: number;
6
6
  readonly signal?: AbortSignal;
@@ -34,10 +34,24 @@ export type PaseoAgent = Steerable<KeyedSendOptions> & {
34
34
  readonly harness: 'paseo';
35
35
  readonly agentId: string;
36
36
  };
37
- export type Agent = OpenCodeAgent | ClaudeAgent | PiAgent | PaseoAgent;
37
+ export type CodexAppServerAgent = Steerable & Queueable & {
38
+ readonly harness: 'codex';
39
+ readonly transport: 'app-server';
40
+ readonly sessionId: string;
41
+ };
42
+ export type CodexCliAgent = Queueable & {
43
+ readonly harness: 'codex';
44
+ readonly transport: 'cli';
45
+ readonly sessionId: string;
46
+ };
47
+ export type CodexAgent = CodexAppServerAgent | CodexCliAgent;
48
+ export type Agent = OpenCodeAgent | ClaudeAgent | PiAgent | PaseoAgent | CodexAgent;
38
49
  export declare function connect(options: OpenCodeConnectOptions): Promise<OpenCodeAgent>;
39
50
  export declare function connect(options: ClaudeConnectOptions): Promise<ClaudeAgent>;
40
51
  export declare function connect(options: PiConnectOptions): Promise<PiAgent>;
41
52
  export declare function connect(options: PaseoConnectOptions): Promise<PaseoAgent>;
53
+ export declare function connect(options: CodexCliConnectOptions): Promise<CodexCliAgent>;
54
+ export declare function connect(options: CodexAppServerConnectOptions): Promise<CodexAppServerAgent>;
55
+ export declare function connect(options: CodexConnectOptions): Promise<CodexAgent>;
42
56
  export { DeliveryError };
43
57
  export type { DeliveryErrorCode } from './errors.js';
@@ -25,11 +25,30 @@ function failure(error, phase, id) {
25
25
  const code = attempt.state === 'unavailable'
26
26
  ? attempt.code === 'aborted' ? 'aborted' : attempt.code === 'timeout' ? 'timeout'
27
27
  : attempt.code === 'authentication_failed' ? 'authentication_failed' : 'unavailable'
28
- : attempt.state === 'rejected' ? attempt.code === 'not_found' ? 'session_not_found' : 'rejected'
28
+ : attempt.state === 'rejected' ? attempt.code === 'not_found' ? 'session_not_found'
29
+ : attempt.code === 'unsupported_version' ? 'unsupported_version' : 'rejected'
29
30
  : codes[attempt.code ?? 'transport'] ?? 'transport';
30
31
  return new DeliveryError(code, attempt.state === 'unknown', id ?? attempt.inputId, attempt.status);
31
32
  }
32
33
  const harnesses = {
34
+ 'codex-cli': {
35
+ harness: 'codex', transport: 'cli', proof: 'admitted', accepted: 'admitted', coordinate: 'sessionId', keyed: false, queue: true, steer: false,
36
+ id: (result) => result.inputId,
37
+ open: async (options) => {
38
+ const { connectCodexCli } = await import('./codex-cli.js');
39
+ const target = await connectCodexCli(options);
40
+ return { coordinate: target.coordinate, send: (text, value) => target.send(text, value) };
41
+ },
42
+ },
43
+ codex: {
44
+ harness: 'codex', transport: 'app-server', proof: 'admitted', accepted: 'admitted', coordinate: 'sessionId', keyed: false, queue: true,
45
+ id: (result) => result.inputId,
46
+ open: async (options) => {
47
+ const { connectCodex, sendCodexText } = await import('./codex.js');
48
+ const target = await connectCodex(options);
49
+ return { coordinate: target.sessionId, send: (text, value) => sendCodexText(target, text, value) };
50
+ },
51
+ },
33
52
  opencode: {
34
53
  harness: 'opencode', proof: 'admitted', accepted: 'accepted', coordinate: 'sessionId', keyed: true, queue: true,
35
54
  id: (result) => result.inputId,
@@ -70,8 +89,9 @@ const harnesses = {
70
89
  export async function connect(options) {
71
90
  const harness = isRecord(options) && typeof options.harness === 'string' ? options.harness : undefined;
72
91
  // An own-key check keeps Object.prototype names out of the transport table.
73
- const spec = harness !== undefined && Object.hasOwn(harnesses, harness) ? harnesses[harness] : undefined;
74
- if (spec === undefined)
92
+ const key = harness === 'codex' && isRecord(options) && options.transport === 'cli' ? 'codex-cli' : harness;
93
+ const spec = key !== undefined && Object.hasOwn(harnesses, key) ? harnesses[key] : undefined;
94
+ if (spec === undefined || spec.harness !== harness)
75
95
  throw new DeliveryError('invalid_arguments');
76
96
  let opened;
77
97
  try {
@@ -86,6 +106,8 @@ export async function connect(options) {
86
106
  throw new DeliveryError('invalid_arguments');
87
107
  // An unusable caller id is an argument precondition; the native sender rejects it.
88
108
  const id = options.id;
109
+ if (!spec.keyed && id !== undefined)
110
+ throw new DeliveryError('invalid_arguments');
89
111
  try {
90
112
  const result = await opened.send(text, spec.keyed
91
113
  ? { delivery, inputId: id, timeoutMs: options.timeoutMs, signal: options.signal }
@@ -101,8 +123,11 @@ export async function connect(options) {
101
123
  const agent = {
102
124
  harness: spec.harness,
103
125
  [spec.coordinate]: opened.coordinate,
104
- steer: (text, value) => send(text, value, 'steer'),
105
126
  };
127
+ if (spec.transport)
128
+ agent.transport = spec.transport;
129
+ if (spec.steer !== false)
130
+ agent.steer = (text, value) => send(text, value, 'steer');
106
131
  if (spec.queue)
107
132
  agent.queue = (text, value) => send(text, value, 'queue');
108
133
  return Object.freeze(agent);
@@ -1,3 +1,4 @@
1
+ import type { CodexBoundaryStore } from './codex-boundary.js';
1
2
  declare const targetBrand: unique symbol;
2
3
  export interface OpenCodeTarget {
3
4
  readonly harness: 'opencode';
@@ -19,7 +20,12 @@ export interface PaseoTarget {
19
20
  readonly agentId: string;
20
21
  readonly [targetBrand]: true;
21
22
  }
22
- export type Target = OpenCodeTarget | ClaudeTarget | PiTarget | PaseoTarget;
23
+ export interface CodexTarget {
24
+ readonly harness: 'codex';
25
+ readonly sessionId: string;
26
+ readonly [targetBrand]: true;
27
+ }
28
+ export type Target = OpenCodeTarget | ClaudeTarget | PiTarget | PaseoTarget | CodexTarget;
23
29
  export type Delivery = 'steer' | 'queue';
24
30
  export interface OpenCodeSendTextOptions {
25
31
  readonly delivery?: Delivery;
@@ -45,7 +51,13 @@ export interface PaseoSendTextOptions {
45
51
  readonly timeoutMs?: number;
46
52
  readonly signal?: AbortSignal;
47
53
  }
48
- export type SendTextOptions = OpenCodeSendTextOptions | ClaudeSendTextOptions | PiSendTextOptions | PaseoSendTextOptions;
54
+ export interface CodexSendTextOptions {
55
+ readonly delivery?: Delivery;
56
+ readonly inputId?: never;
57
+ readonly timeoutMs?: number;
58
+ readonly signal?: AbortSignal;
59
+ }
60
+ export type SendTextOptions = OpenCodeSendTextOptions | ClaudeSendTextOptions | PiSendTextOptions | PaseoSendTextOptions | CodexSendTextOptions;
49
61
  export interface OpenCodeConnectOptions {
50
62
  readonly harness: 'opencode';
51
63
  readonly sessionId: string;
@@ -88,6 +100,32 @@ export interface PaseoConnectOptions {
88
100
  readonly timeoutMs?: number;
89
101
  readonly signal?: AbortSignal;
90
102
  }
103
+ /** Connect only to an existing app-server; never starts a service or resumes a thread. */
104
+ export interface CodexAppServerConnectOptions {
105
+ readonly harness: 'codex';
106
+ readonly transport?: 'app-server';
107
+ readonly sessionId: string;
108
+ readonly endpoint?: string;
109
+ readonly token?: string;
110
+ readonly codexHome?: string;
111
+ readonly timeoutMs?: number;
112
+ readonly signal?: AbortSignal;
113
+ }
114
+ /** Bind CLI coordinates and the same lifecycle source used by the receiver's hooks. */
115
+ export interface CodexCliConnectOptions {
116
+ readonly harness: 'codex';
117
+ readonly transport: 'cli';
118
+ readonly sessionId: string;
119
+ readonly boundaries: Pick<CodexBoundaryStore, 'queueEligible'>;
120
+ readonly codexHome?: string;
121
+ readonly endpoint?: string;
122
+ readonly bin?: string;
123
+ readonly args?: readonly string[];
124
+ readonly env?: NodeJS.ProcessEnv;
125
+ readonly timeoutMs?: number;
126
+ readonly signal?: AbortSignal;
127
+ }
128
+ export type CodexConnectOptions = CodexAppServerConnectOptions | CodexCliConnectOptions;
91
129
  interface OpenCodeAttempt {
92
130
  readonly harness: 'opencode';
93
131
  readonly sessionId: string;
@@ -158,5 +196,22 @@ export type PaseoDeliveryResult = (PaseoAttempt & {
158
196
  readonly state: 'unknown';
159
197
  readonly code: 'timeout' | 'aborted' | 'transport';
160
198
  });
161
- export type DeliveryResult = OpenCodeDeliveryResult | ClaudeDeliveryResult | PiDeliveryResult | PaseoDeliveryResult;
199
+ interface CodexAttempt {
200
+ readonly harness: 'codex';
201
+ readonly sessionId: string;
202
+ readonly inputId: string;
203
+ }
204
+ export type CodexDeliveryResult = (CodexAttempt & {
205
+ readonly state: 'admitted';
206
+ }) | (CodexAttempt & {
207
+ readonly state: 'unknown';
208
+ readonly code: 'timeout' | 'aborted' | 'transport' | 'invalid_response';
209
+ }) | (CodexAttempt & {
210
+ readonly state: 'unavailable';
211
+ readonly code: 'timeout' | 'aborted' | 'transport' | 'authentication_failed';
212
+ }) | (CodexAttempt & {
213
+ readonly state: 'rejected';
214
+ readonly code: 'not_found' | 'refused' | 'unsupported_version';
215
+ });
216
+ export type DeliveryResult = OpenCodeDeliveryResult | ClaudeDeliveryResult | PiDeliveryResult | PaseoDeliveryResult | CodexDeliveryResult;
162
217
  export {};
@@ -6,6 +6,10 @@ export interface MigrationCounts {
6
6
  readonly receipts: number;
7
7
  readonly leases: number;
8
8
  readonly routes: number;
9
+ readonly recoveredSeats: number;
10
+ readonly unownedStanding: number;
11
+ readonly sharedBodySeats: number;
12
+ readonly skippedEvidence: number;
9
13
  }
10
14
  export interface MigrationOutcome {
11
15
  readonly status: 'migrated' | 'already-current';
@@ -229,25 +229,24 @@ function establishingJoin(driver, participant) {
229
229
  return join.index;
230
230
  }
231
231
  function planSeats(location, roster, routes, owners, driver) {
232
- const standing = roster.filter((participant) => participant.joined);
233
- return standing.map((participant) => {
232
+ const seats = [];
233
+ const localRoutes = routes.filter(route => canonicalPathSync(route.location) === location);
234
+ for (const participant of roster.filter((participant) => participant.joined)) {
234
235
  const joined = establishingJoin(driver, participant);
235
- const candidates = routes.filter((route) => canonicalPathSync(route.location) === location && nameKey(route.participant) === nameKey(participant.name));
236
+ const candidates = localRoutes.filter((route) => nameKey(route.participant) === nameKey(participant.name));
236
237
  const owner = owners.find(row => nameKey(row.participant) === nameKey(participant.name));
237
- assertSource(owner !== undefined, `standing participant ${participant.name} has no legacy owner.`);
238
- const { body } = owner;
239
- assertSource(candidates.length > 0 && candidates.every(route => sameBody(route.body, body)), `standing participant ${participant.name} has no unique legacy body agreeing with its owner.`);
240
- const shared = [...owners, ...routes.filter(route => canonicalPathSync(route.location) === location)]
241
- .some(row => nameKey(row.participant) !== nameKey(participant.name) && sameBody(row.body, body));
242
- assertSource(!shared, `standing participant ${participant.name} shares a legacy body with another owner.`);
238
+ const body = owner?.body ?? candidates[0]?.body;
239
+ if (body === undefined || !candidates.every(route => sameBody(route.body, body)))
240
+ continue;
243
241
  const address = uniqueSeatAddress(candidates);
244
- return {
242
+ seats.push({
245
243
  participant: participant.name,
246
244
  body,
247
245
  generation: formatActivityId(joined),
248
246
  ...(address === undefined ? {} : { wakeAddress: address }),
249
- };
250
- });
247
+ });
248
+ }
249
+ return seats;
251
250
  }
252
251
  function tableToken(database, table, order) {
253
252
  const rows = database.prepare(`SELECT * FROM ${table} ORDER BY ${order}`).all();
@@ -313,15 +312,20 @@ function inspectLegacyRowArtifact(database, location, host) {
313
312
  leaseGenerations.push({ ownerJson: row.owner_json, generation: formatActivityId(ownerParticipant.lastJoinIndex) });
314
313
  }
315
314
  const seats = planSeats(location, roster, routes, host.owners, driver);
315
+ const standingCount = roster.filter(participant => participant.joined).length;
316
+ const deliveries = planLegacyDeliveries(host.evidence, seats, driver);
316
317
  const counts = {
317
318
  activities: activityCount,
318
319
  receipts: Number(database.prepare('SELECT COUNT(*) AS n FROM receipts').get().n),
319
320
  leases: leaseOwners.length,
320
321
  routes: routes.length,
322
+ recoveredSeats: seats.length,
323
+ unownedStanding: standingCount - seats.length,
324
+ sharedBodySeats: seats.filter(seat => seats.some(other => other !== seat && sameBody(other.body, seat.body))).length,
325
+ skippedEvidence: host.evidence.length - deliveries.length,
321
326
  };
322
327
  const sourceToken = legacySourceToken(database);
323
- return { standing: seats.length, seats, leaseGenerations, sourceToken, counts,
324
- deliveries: planLegacyDeliveries(host.evidence, seats, driver) };
328
+ return { standing: standingCount, seats, leaseGenerations, sourceToken, counts, deliveries };
325
329
  }
326
330
  function legacyPayloadHash(payload) {
327
331
  return createHash('sha256').update(payload).digest('hex');
@@ -348,47 +352,49 @@ function legacyDeliveryPayload(source, current) {
348
352
  }
349
353
  function planLegacyDeliveries(records, seats, driver) {
350
354
  const byGroup = new Map();
351
- const tokens = new Set(), completedBodies = new Map();
355
+ const completedBodies = new Map();
352
356
  for (const source of records) {
357
+ // Validate structure even when no surviving owner can accept this row.
358
+ assertSource(source.kind === 'wake' || source.kind === 'presentation', 'delivery evidence kind is unsupported.');
359
+ assertSource(['dispatching', 'accepted', 'failed', 'unknown', 'released', 'presented', 'clipped'].includes(source.outcome), 'delivery evidence outcome is unsupported.');
360
+ assertSource(source.session.length > 0
361
+ && (source.channel === undefined || typeof source.channel === 'string' && source.channel.length > 0)
362
+ && (source.agent === undefined || typeof source.agent === 'string' && source.agent.length > 0), 'delivery evidence body is malformed.');
363
+ const index = parseActivityId(source.activity);
364
+ assertSource(index !== undefined, 'delivery evidence activity id is malformed.');
365
+ const at = sourceFinite(source.at);
366
+ const expiresAt = source.expiresAt === undefined ? undefined : sourceFinite(source.expiresAt);
367
+ const claimToken = source.claimToken;
368
+ assertSource(claimToken === undefined || typeof claimToken === 'string' && claimToken.length > 0, 'delivery claim token is malformed.');
369
+ assertSource(source.attemptN === undefined || Number.isSafeInteger(source.attemptN) && source.attemptN >= 0, 'delivery attempt number is malformed.');
353
370
  const participant = driver.participant(source.participant);
371
+ const seat = seats.find(seat => nameKey(seat.participant) === nameKey(source.participant));
372
+ const payload = legacyDeliveryPayload(source, seat?.wakeAddress);
354
373
  const native = source.kind === 'wake' && source.nativeDelivery !== undefined;
355
- if (!participant?.joined && !native)
356
- continue; // Noncritical completed diagnostics are not imported.
357
- assertSource(participant?.joined || participant?.done, `native delivery evidence for ${source.participant} has no unique participant.`);
374
+ if (!participant?.joined && !(participant?.done && native))
375
+ continue;
376
+ if (participant.joined && (seat === undefined || seat.body.session !== source.session
377
+ || source.agent !== seat.body.agent || source.channel !== undefined && source.channel !== seat.body.channel))
378
+ continue;
379
+ const harness = source.nativeDelivery?.harness;
380
+ if (native && seat !== undefined && harness !==
381
+ (seat.body.channel === 'claude-code' ? 'claude' : seat.body.channel === 'opencode' ? 'opencode' : undefined))
382
+ continue;
358
383
  const joined = establishingJoin(driver, participant);
359
384
  const generation = formatActivityId(joined);
360
- const seat = seats.find(seat => nameKey(seat.participant) === nameKey(participant.name));
361
- if (participant.joined)
362
- assertSource(seat !== undefined && seat.body.session === source.session
363
- && source.agent === seat.body.agent && (source.channel === undefined || source.channel === seat.body.channel), `delivery evidence for standing participant ${source.participant} has no unique owner.`);
364
- if (native && seat !== undefined)
365
- assertSource(source.nativeDelivery?.harness ===
366
- (seat.body.channel === 'claude-code' ? 'claude' : seat.body.channel === 'opencode' ? 'opencode' : undefined), 'native delivery harness conflicts with its owner body.');
367
- const index = parseActivityId(source.activity);
368
- assertSource(index !== undefined && index > joined && driver.activity(index)?.kind === 'say'
369
- && driver.directedAt(participant.name, index) !== undefined, `delivery evidence for ${source.participant} has no unique activity in its join generation.`);
370
- const payload = legacyDeliveryPayload(source, seat?.wakeAddress);
385
+ if (index <= joined || driver.activity(index)?.kind !== 'say' || driver.directedAt(participant.name, index) === undefined)
386
+ continue;
371
387
  if (participant.done) {
372
388
  const done = driver.activityHeads({ actors: [participant.name], kinds: ['done'] }, { edge: 'newest', limit: 1 }).items[0];
373
- assertSource(done !== undefined && index < done.index && source.session.length > 0, `native delivery evidence for ${source.participant} has no unique completed generation.`);
374
- const harness = source.nativeDelivery.harness;
389
+ if (done === undefined || index >= done.index)
390
+ continue;
375
391
  const body = { channel: harness === 'claude' ? 'claude-code' : 'opencode', session: source.session,
376
392
  ...(source.agent === undefined ? {} : { agent: source.agent }) };
377
- validateBody(body);
393
+ if (source.channel !== undefined && source.channel !== body.channel)
394
+ continue;
378
395
  const coordinate = JSON.stringify([body.channel, body.session, body.agent]);
379
- const previous = completedBodies.get(nameKey(participant.name));
380
- assertSource(previous === undefined || previous === coordinate, 'completed native delivery owner mapping is ambiguous.');
381
- completedBodies.set(nameKey(participant.name), coordinate);
382
- }
383
- assertSource(source.kind === 'wake' || source.kind === 'presentation', 'delivery evidence kind is unsupported.');
384
- assertSource(['dispatching', 'accepted', 'failed', 'unknown', 'released', 'presented', 'clipped'].includes(source.outcome), 'delivery evidence outcome is unsupported.');
385
- const at = sourceFinite(source.at);
386
- const expiresAt = source.expiresAt === undefined ? undefined : sourceFinite(source.expiresAt);
387
- const claimToken = source.claimToken;
388
- assertSource(claimToken === undefined || typeof claimToken === 'string' && claimToken.length > 0, 'delivery claim token is malformed.');
389
- if (claimToken !== undefined) {
390
- assertSource(!tokens.has(claimToken), 'delivery claim token is ambiguous.');
391
- tokens.add(claimToken);
396
+ const key = nameKey(participant.name), previous = completedBodies.get(key);
397
+ completedBodies.set(key, previous === undefined || previous === coordinate ? coordinate : null);
392
398
  }
393
399
  const group = JSON.stringify([nameKey(participant.name), generation, index, source.kind]);
394
400
  const row = {
@@ -409,14 +415,19 @@ function planLegacyDeliveries(records, seats, driver) {
409
415
  }
410
416
  const deliveries = [];
411
417
  for (const rows of byGroup.values()) {
412
- const attempts = rows.map(row => row.attemptN);
413
- const explicit = attempts.filter((attempt) => attempt !== undefined);
414
- assertSource(explicit.every((attempt) => Number.isSafeInteger(attempt) && attempt >= 0) && new Set(explicit).size === explicit.length, 'delivery attempt mapping is ambiguous.');
415
- if (explicit.length !== rows.length)
416
- assertSource(rows.length === 1 && explicit.length === 0, 'delivery attempt mapping is ambiguous.');
418
+ if (completedBodies.get(nameKey(rows[0].participant)) === null)
419
+ continue;
420
+ const explicit = rows.flatMap(row => row.attemptN === undefined ? [] : [row.attemptN]);
421
+ if (new Set(explicit).size !== explicit.length || explicit.length !== rows.length && rows.length !== 1)
422
+ continue;
417
423
  rows.forEach((row, index) => deliveries.push({ ...row, attemptN: explicit.length === 0 ? 0 : explicit[index] }));
418
424
  }
419
- return deliveries;
425
+ const tokens = new Map();
426
+ for (const row of deliveries) {
427
+ if (row.claimToken !== undefined)
428
+ tokens.set(row.claimToken, (tokens.get(row.claimToken) ?? 0) + 1);
429
+ }
430
+ return deliveries.filter(row => row.claimToken === undefined || tokens.get(row.claimToken) === 1);
420
431
  }
421
432
  const V4_REBUILT_TABLES = ['square_meta', 'catch_leases'];
422
433
  function tableDefinition(name) {
@@ -0,0 +1,79 @@
1
+ # Codex delivery
2
+
3
+ Square uses the shared Codex leaves in `packages/agent-delivery/src`, rather than
4
+ maintaining a second hook protocol, boundary store or CLI sender. The standalone
5
+ package's [README](../packages/agent-delivery/README.md) describes both its native
6
+ app-server capability and its host-owned hook integration.
7
+
8
+ ## Square's hook path
9
+
10
+ `plugins/codex/hooks/hooks.json` invokes `square codex-hook`:
11
+
12
+ - `SessionStart`, `SessionResume` and `UserPromptSubmit` invalidate the previous
13
+ Stop observation. `SessionEnd` marks the session ended and ends Square's owned
14
+ memberships. Later Stop/tool callbacks cannot re-enable queue until a new
15
+ `SessionStart` or `SessionResume`.
16
+ - `PostToolUse` asks Square for current pending activity and returns the existing
17
+ bounded `additionalContext` presentation.
18
+ - `Stop` records queue eligibility and sweeps pending work, but does not itself
19
+ claim that a preview was presented.
20
+
21
+ The shared handler owns hook framing, lifecycle sequencing, its cancellation
22
+ budget and bounded sweeps. Square supplies activity selection, formatting,
23
+ artifact-owned presentation claims, cancellation, membership cleanup and
24
+ pending-work sweeps. The handler cooperatively awaits Square's pull: the signal
25
+ cancels work before presentation, but a successful presentation's `settleAs`
26
+ finishes independently of request cancellation. Even when that settlement crosses
27
+ the internal budget, the hook waits for it and returns the associated context;
28
+ it never abandons settlement that can mark activity seen without returning text.
29
+ After expiry, no sweep starts, and a sweep timeout cannot erase successful pull
30
+ output. Settlement must still finish within Codex's external hook timeout. Hook inbox lookup uses
31
+ an exact Body (`{ channel: 'codex', session }`), not a bare session id. The shared
32
+ boundary store uses Square's caller-selected `SQUARE_CODEX_BOUNDARIES` file,
33
+ defaulting to `~/.square/codex-boundaries.json`; its JSON observations are not
34
+ conversation storage and are not proof of current native execution or consumption.
35
+ Square's format5 artifact remains authoritative for seats and delivery evidence.
36
+
37
+ `CodexQueueTransport` implements the existing `WakeTransportPort` and uses the public
38
+ `connect({ harness: 'codex', transport: 'cli', sessionId, boundaries, ... })`
39
+ and `agent.queue(text)` interface, just like independent hosts. Square reads its
40
+ stored pending preview and finishes its currentness gate first; the bound client
41
+ checks boundary eligibility once inside `queue()`, then launches the CLI. No
42
+ caller-side lifecycle check is duplicated. Outcomes continue through the existing
43
+ artifact-owned delivery path; no host ledger or new route kind is introduced.
44
+
45
+ Both CLI coordinate creation and MCP call-local Codex coordinates capture the
46
+ recipient's `CODEX_HOME` (or home default), so later senders use the recipient's
47
+ CLI configuration rather than their own. Only an explicitly configured
48
+ `SQUARE_CODEX_ENDPOINT` adds a remote endpoint. `SQUARE_CODEX_BIN` remains
49
+ Square's executable selection; it is not an environment convention of the
50
+ independent package.
51
+
52
+ By default the sender runs `codex queue --thread ... --message ...`, allowing the
53
+ CLI's normal connection/startup behavior. Only an explicit endpoint adds
54
+ `--remote`. This does not make another host's private stdio pipe attachable.
55
+ After an observed `SessionEnd`, Square does not dispatch queue; the bound lifecycle
56
+ check also catches an end observed while the currentness gate was running.
57
+ It cannot retract a submission already launched or infer a missed end hook.
58
+ Queue success means native admission only; its receipt id is a local attempt
59
+ UUID, not a server message id. The child runs asynchronously and respects the
60
+ send's abort signal/deadline. Timeout, killed child and unexplained post-launch
61
+ failures remain unknown, not a safe reason to resend. A receiver with no tool
62
+ calls waits for Stop; this path is not instantaneous native steer.
63
+
64
+ ## Optional native app-server capability
65
+
66
+ Independent consumers can use `connect({ harness: 'codex', sessionId, endpoint })`
67
+ from `@astrosheep/agent-delivery` for fenced busy steer and native queue.
68
+ `transport: 'app-server'` is the explicit spelling and the default when omitted;
69
+ it exposes both methods. `transport: 'cli'` exposes queue only and requires the
70
+ host's hook lifecycle source at connection time. There is no automatic fallback
71
+ between transports. The app-server transport never starts a service or calls
72
+ `thread/resume`. This does not replace Square's hook policy or add a new persisted
73
+ Square route kind. A loaded thread's owner remains its existing host, not the
74
+ external sender.
75
+
76
+ Neither path implements a read-receipt roundtrip. Admission and runtime
77
+ observations must not be presented as model comprehension or completion. An
78
+ unknown outcome stays unknown. Installing, restarting or changing a real Codex
79
+ server is outside this integration.
@@ -38,25 +38,44 @@ artifact. Its latest owner row for each participant at this artifact is selected
38
38
  by source `(effectiveAt, seq)` only: no wall clock, retention, expiry, or ownership
39
39
  history reconstruction is involved.
40
40
 
41
- - A standing seat requires an agreeing owner Body (`channel`, `session`, optional
42
- `agent`), route Body, and verified establishing last join. Routes alone never
43
- establish ownership. An absent agent means the top-level body; a known agent is
44
- preserved and compared exactly. Repeated matching route registrations are
45
- acceptable; a wake address is carried forward only when they agree on it.
46
- - Missing owners or routes, conflicting or shared body mappings, and unverifiable
47
- joins refuse before publication. Resolve these in the old runtime before
48
- retrying; migration does not guess or synthesize authority.
49
- - Standing wake/presentation evidence maps only to that verified current join
50
- generation and an existing directed say after the join. Evidence older than the
51
- current generation, conflicting owner/session mappings, duplicate claim tokens,
52
- or ambiguous attempt numbers refuse. A single record without an attempt number
53
- receives attempt 0; missing numbers among multiple attempts are ambiguous.
41
+ - A legacy owner Body (`channel`, `session`, optional `agent`) establishes a
42
+ standing seat only when it agrees with all of that name's local route Bodies
43
+ and the establishing last join is verified. An owner without routes needs no
44
+ wake address. Without a legacy owner, one unambiguous Body recorded by that
45
+ participant's own routes establishes its seat instead. An absent agent means
46
+ the top-level body; a known agent is preserved and compared exactly. Repeated
47
+ matching registrations are acceptable; a wake address is carried forward only
48
+ when the routes agree on it. Routes are location-bound: moving an artifact copy
49
+ to another path does not establish that its original routes apply there. An
50
+ off-location copy is not ownership-recovery evidence for the original location.
51
+ - Without an owner or any route Body, the participant stays standing without a
52
+ seat. Its source participant state, activities, attention, receipts, landed
53
+ audience and catch lease are preserved. No Body, route or departure is invented;
54
+ the name has no write authority or delivery eligibility. Doctor retains its
55
+ strict history-derived consistency checks.
56
+ - A Body may own several participant seats. Each name retains its own seat,
57
+ establishing join generation and wake address; sharing does not merge names or
58
+ choose one name over another. All evidence for each name must agree: if its
59
+ routes disagree with each other or with its legacy owner Body, that name stays
60
+ standing without a seat. Disagreement does not abort migration and neither side
61
+ is chosen as authoritative. Unverifiable joins still refuse before publication.
62
+ Migration does not guess or synthesize authority.
63
+ - Evidence structure is validated before placement, including rows that cannot be
64
+ imported. Malformed fields, invalid activity IDs or malformed native correlation
65
+ still refuse without changing either source.
66
+ - Wake/presentation evidence for a recovered seat is imported only when its Body
67
+ agrees and it names an existing directed say after the verified current join.
68
+ A missing seat, conflicting session/agent/channel or native harness, unknown
69
+ participant, or activity outside the generation causes the row to be skipped,
70
+ not a failed conversion. Nothing is reassigned to another owner.
54
71
  - Done participants get **no seat or completed coordinate**. Only native wake
55
72
  correlation directly attributable to their verified latest join and a directed
56
73
  activity between that join and done is retained for retired-token recognition.
57
- It does not authorize admission, completion, or presentation. Ambiguous critical
58
- native mappings refuse rather than disappear. Migrated Claude native addresses
59
- include `sessionId` on both seats and evidence routes.
74
+ It does not authorize admission, completion, or presentation. Conflicting
75
+ completed Body mappings, duplicate claim tokens and ambiguous attempt numbering
76
+ cause all affected rows to be skipped, rather than selecting a winner. A single
77
+ record without an attempt number receives attempt 0. Migrated Claude native
78
+ addresses include `sessionId` on both seats and evidence routes.
60
79
 
61
80
  This is deliberately a narrow, lossy conversion, not a general history importer.
62
81
  Conversation facts (activity IDs, bodies and audience), receipts and source revision
@@ -65,15 +84,23 @@ correlation survive direct mappings. Completed non-native evidence and noncritic
65
84
  metadata—including old presence history, epochs, diagnostic messages, native
66
85
  observation histories and arbitrary legacy fields—are not imported. The legacy
67
86
  ledger is read-only and remains untouched, so it is still available for offline
68
- diagnostics. An absent ledger cannot establish standing ownership; an unreadable
69
- or unsupported ledger refuses before changing the square.
70
-
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,
73
- receipts, lease contents, and the artifact revision are preserved. Legacy routes
74
- are decoded only during migration and their table is removed. Existing lease
75
- generations are resolved only for exact owner spellings that match a standing
76
- participant; other lease rows retain their legacy, unbound generation.
87
+ diagnostics. An absent ledger still permits route-derived seats; an unreadable
88
+ or unsupported ledger refuses before changing the square. Evidence without a
89
+ current or verified completed owner is skipped and counted. The source ledger is
90
+ retained for offline diagnosis; skipped evidence never grants delivery authority.
91
+
92
+ After successful preflight, one SQLite transaction imports seats and evidence
93
+ and adds delivery deferral support, then publishes the current format. All
94
+ existing activities, their indices and audience, participant state, attention,
95
+ receipts, lease contents, and the source revision are preserved. Legacy routes
96
+ are consumed only during migration. Lease generations are resolved only for exact
97
+ owner spellings that match a standing participant; other lease rows retain their
98
+ legacy, unbound generation. The command reports recovered seats and unowned
99
+ standing names, and recovered seats whose Body is shared with another recovered
100
+ seat, and skipped evidence rows, alongside the activity, receipt, lease and source
101
+ route counts. The shared count measures seats, not distinct Bodies or shared groups.
102
+ The skipped count includes every wake/presentation row read from the selected
103
+ legacy ledger but not imported, including completed non-native diagnostics.
77
104
  The new delivery revision starts at zero. There is no intermediate published
78
105
  format, and any failure before commit rolls back the conversion.
79
106
 
@@ -90,8 +117,7 @@ reader; migration consumes the old rows only to resolve seats, then drops the le
90
117
  table as part of the format change.
91
118
 
92
119
  Unrelated databases, unsupported formats, non-`DELETE` journal modes, unknown
93
- schemas, malformed headers or rows, unresolved standing ownership, and ambiguous
94
- standing or native delivery evidence are refused. The source artifact is preserved on every
120
+ schemas, malformed headers, rows or native correlation are refused. The source artifact is preserved on every
95
121
  refusal. Migration is not a repair command and never rewrites journal mode to make
96
122
  conversion possible. A HostLedger that itself requires migration must be migrated
97
123
  separately before Square migration can import its delivery evidence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.90",
3
+ "version": "0.3.92",
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": {