@astrosheep/square 0.4.3 → 0.4.4

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.
@@ -118,7 +118,7 @@ export async function runClaudeHook(text, env = process.env, sink = value => { p
118
118
  && input.tool_name?.startsWith('mcp__plugin_square_square__') && input.tool_use_id) {
119
119
  await endClaudeMcpCallForSession(input.tool_use_id, input.session_id, env, input.agent_id).catch(() => undefined);
120
120
  }
121
- if (['PreToolUse', 'PostToolUse', 'PostToolUseFailure'].includes(input.hook_event_name)) {
121
+ if (input.hook_event_name === 'PostToolUse') {
122
122
  await presentPendingAtBoundary(owner, async (context) => {
123
123
  await sink(`${JSON.stringify({ hookSpecificOutput: { hookEventName: input.hook_event_name, additionalContext: context } })}\n`);
124
124
  }, env, AbortSignal.timeout(4500));
@@ -1,8 +1,8 @@
1
1
  import { type CodexHookInput } from './packages/agent-delivery/src/codex-hook.js';
2
2
  export type HookTrace = (line: string) => void;
3
3
  export declare const CODEX_HOOK_BUDGET_MS = 4500;
4
- /** sink resolves only after stdout has accepted the complete hook response. */
5
- export declare function codexHookResponse(value: unknown, sink: ((text: string) => void | Promise<void>) | undefined, env?: NodeJS.ProcessEnv, trace?: HookTrace): Promise<object | undefined>;
4
+ /** Active delivery uses PostToolUse only; each async Stop owns its own wait. */
5
+ export declare function codexHookResponse(value: unknown, sink: ((text: string) => void | Promise<void>) | undefined, env?: NodeJS.ProcessEnv, trace?: HookTrace): Promise<void>;
6
6
  export declare function parseCodexHookText(text: string): CodexHookInput | undefined;
7
7
  /** The caller owns stdout; no receipt is written when no output sink is supplied. */
8
8
  export declare function runCodexHook(inputText: string, env?: NodeJS.ProcessEnv, sink?: (text: string) => void | Promise<void>): Promise<string>;
@@ -1,13 +1,12 @@
1
- import { handleCodexHook, parseCodexHook } from './packages/agent-delivery/src/codex-hook.js';
1
+ import { parseCodexHook } from './packages/agent-delivery/src/codex-hook.js';
2
2
  import { presentPendingAtBoundary } from './boundary-presentation.js';
3
3
  import { DeliveryLedger } from './delivery-ledger.js';
4
4
  import { harnessOwner } from './owner.js';
5
5
  import { attachDeliverySessionRoutes, detachDeliverySession } from './session-lifecycle.js';
6
- import { codexBoundaryStore } from './codex-boundary-state.js';
7
6
  import { codexWakeAddress } from './codex-address.js';
8
7
  export const CODEX_HOOK_BUDGET_MS = 4500;
9
8
  const defaultTrace = line => { process.stderr.write(`${line}\n`); };
10
- /** sink resolves only after stdout has accepted the complete hook response. */
9
+ /** Active delivery uses PostToolUse only; each async Stop owns its own wait. */
11
10
  export async function codexHookResponse(value, sink, env = process.env, trace = defaultTrace) {
12
11
  const input = parseCodexHook(value);
13
12
  if (!input)
@@ -16,47 +15,35 @@ export async function codexHookResponse(value, sink, env = process.env, trace =
16
15
  const timeoutMs = Math.min(input.hook_event_name === 'Interrupt' || input.hook_event_name === 'SessionEnd' ? 2500 : CODEX_HOOK_BUDGET_MS, Number.isFinite(configured) && configured > 0 ? configured : CODEX_HOOK_BUDGET_MS);
17
16
  const signal = AbortSignal.timeout(timeoutMs);
18
17
  const owner = harnessOwner({ channel: 'codex', session: input.session_id });
19
- // Capture before any asynchronous boundary update; a replacement attachment
20
- // must never inherit this callback's cancellation or detachment.
21
- let generation;
22
- if (input.hook_event_name === 'SessionEnd' || input.hook_event_name === 'Interrupt') {
18
+ if (input.hook_event_name === 'SessionStart') {
19
+ await attachDeliverySessionRoutes(owner, env, [codexWakeAddress(input.session_id, env)], signal);
20
+ }
21
+ else if (input.hook_event_name === 'SessionEnd' || input.hook_event_name === 'Interrupt') {
23
22
  const ledger = await DeliveryLedger.open({ location: env.SQUARE_DELIVERY });
24
23
  try {
25
- generation = (await ledger.session(owner))?.generation;
24
+ const captured = await ledger.session(owner);
25
+ if (!captured || signal.aborted)
26
+ return;
27
+ if (input.hook_event_name === 'SessionEnd')
28
+ await detachDeliverySession(owner, captured.generation, env);
29
+ else
30
+ await ledger.cancel({ owner, generation: captured.generation });
26
31
  }
27
32
  finally {
28
33
  await ledger.close();
29
34
  }
30
35
  }
31
- const result = await handleCodexHook(input, {
32
- boundaries: codexBoundaryStore(env), timeoutMs, signal,
33
- pull: async (_sessionId, pullSignal, event) => {
34
- if (!sink)
35
- return;
36
- return presentPendingAtBoundary(owner, async (context) => {
37
- await sink(`${JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: context } })}\n`);
38
- return context;
39
- }, env, pullSignal);
40
- },
41
- end: async () => { if (generation)
42
- await detachDeliverySession(owner, generation, env); },
43
- interrupt: async () => {
44
- if (!generation || signal.aborted)
45
- return;
46
- const ledger = await DeliveryLedger.open({ location: env.SQUARE_DELIVERY });
47
- try {
48
- await ledger.cancel({ owner, generation });
49
- }
50
- finally {
51
- await ledger.close();
52
- }
53
- },
54
- trace: name => trace(`square-codex-hook: stage=${name}`),
55
- });
56
- // Disarm before rotating the native attachment, even if there is no old session.
57
- if (input.hook_event_name === 'SessionStart' && !signal.aborted)
58
- await attachDeliverySessionRoutes(owner, env, [codexWakeAddress(input.session_id, env)], signal);
59
- return result;
36
+ else if (input.hook_event_name === 'PostToolUse' && sink) {
37
+ try {
38
+ await presentPendingAtBoundary(owner, async (context) => {
39
+ await sink(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } })}\n`);
40
+ }, env, signal);
41
+ trace('square-codex-hook: stage=presentation');
42
+ }
43
+ catch (error) {
44
+ trace(`square-codex-hook: stage=presentation error=${error instanceof Error ? error.name : 'Error'}`);
45
+ }
46
+ }
60
47
  }
61
48
  export function parseCodexHookText(text) {
62
49
  try {
@@ -1,7 +1,6 @@
1
- import { type CodexStopEpoch } from './codex-boundary-state.js';
2
1
  import type { DeliveryCard, DeliveryTransport, DeliveryTransportContext, TransportOutcome } from './delivery-ledger.js';
3
2
  export interface CodexQueueTransportOptions {
4
- readonly epoch: CodexStopEpoch;
3
+ readonly turnId: string;
5
4
  readonly address: Readonly<Record<string, string>>;
6
5
  readonly env?: NodeJS.ProcessEnv;
7
6
  }
@@ -1,7 +1,6 @@
1
1
  import { connect, DeliveryError } from './packages/agent-delivery/src/index.js';
2
2
  import { codexIdleAtStop } from './packages/agent-delivery/src/codex.js';
3
3
  import { codexQueueEndpoint } from './packages/agent-delivery/src/codex-endpoint.js';
4
- import { codexBoundaryStore } from './codex-boundary-state.js';
5
4
  import { renderDeliveryReminder } from './delivery-frame.js';
6
5
  /** Instantiated only by the receiver, for one captured Stop epoch. */
7
6
  export class CodexQueueTransport {
@@ -16,21 +15,19 @@ export class CodexQueueTransport {
16
15
  !Object.entries(this.opts.address).every(([key, value]) => address[key] === value))
17
16
  return 'not-capable';
18
17
  const env = this.opts.env ?? process.env;
19
- const boundaries = codexBoundaryStore(env), epoch = this.opts.epoch;
18
+ const turnId = this.opts.turnId;
20
19
  try {
21
20
  // Receiver delivery addresses the already-hosting endpoint explicitly;
22
21
  // disappearing native custody must not trigger CLI service startup.
23
22
  const endpoint = address.endpoint ?? codexQueueEndpoint({ CODEX_HOME: address.codexHome });
24
23
  const agent = await connect({ harness: 'codex', transport: 'cli', sessionId: threadId,
25
24
  boundaries: { queueEligible: async (id) => {
26
- if (!await boundaries.queueEligible(id, epoch))
27
- return false;
28
25
  // Repeat the native sample after claim, immediately before the final
29
- // epoch and ledger fences. These reads are not an atomic native enqueue.
26
+ // ledger fences. These reads are not an atomic native enqueue.
30
27
  if (!await codexIdleAtStop({ harness: 'codex', sessionId: id, codexHome: address.codexHome,
31
- endpoint, timeoutMs: Math.max(1, context.deadline - Date.now()), signal: context.signal }, epoch.turnId))
28
+ endpoint, timeoutMs: Math.max(1, context.deadline - Date.now()), signal: context.signal }, turnId))
32
29
  return false;
33
- return await boundaries.queueEligible(id, epoch) && await context.beforeSend()
30
+ return await context.beforeSend()
34
31
  && !context.signal.aborted && Date.now() < context.deadline;
35
32
  } },
36
33
  env, bin: env.SQUARE_CODEX_BIN, codexHome: address.codexHome, endpoint });
@@ -1,2 +1,2 @@
1
- /** One native async hook owns observation; the lock itself conveys no idle authority. */
1
+ /** One async Stop waits only for its captured native turn; it never adopts another Stop. */
2
2
  export declare function runCodexWatch(inputText: string, env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<string>;
@@ -6,46 +6,45 @@ import { retainDeliveryLedger } from './delivery-lifetime.js';
6
6
  import { harnessOwner } from './owner.js';
7
7
  import { withFileLock } from './file-lock.js';
8
8
  import { canonicalPath } from './canonical-path.js';
9
- import { codexBoundaryStore } from './codex-boundary-state.js';
10
9
  import { codexWakeAddress } from './codex-address.js';
11
10
  import { parseCodexHookText } from './codex-hook.js';
12
11
  import { CodexQueueTransport } from './codex-queue.js';
13
- import { codexIdleAtStop } from './packages/agent-delivery/src/codex.js';
14
- /** One native async hook owns observation; the lock itself conveys no idle authority. */
12
+ import { codexStopState } from './packages/agent-delivery/src/codex.js';
13
+ /** One async Stop waits only for its captured native turn; it never adopts another Stop. */
15
14
  export async function runCodexWatch(inputText, env = process.env, signal) {
16
15
  const input = parseCodexHookText(inputText);
17
16
  if (input?.hook_event_name !== 'Stop' || signal?.aborted)
18
17
  return '';
18
+ const turnId = input.turn_id;
19
19
  const controller = new AbortController();
20
20
  const stop = () => controller.abort();
21
21
  const timer = setTimeout(stop, 86_400_000);
22
22
  const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
23
23
  process.on('SIGTERM', stop);
24
24
  process.on('SIGINT', stop);
25
+ let lifetime;
25
26
  try {
26
27
  const route = codexWakeAddress(input.session_id, env);
27
28
  const home = await canonicalPath(route.address.codexHome);
28
- const coordinate = [input.session_id, route.address.endpoint ?? home];
29
+ const ledger = await DeliveryLedger.open({ location: deliveryLedgerLocation(env) });
30
+ lifetime = retainDeliveryLedger(ledger);
31
+ const owner = harnessOwner({ channel: 'codex', session: input.session_id });
32
+ const captured = await ledger.session(owner);
33
+ if (captured?.state !== 'attached')
34
+ return '';
35
+ // Same-turn duplicates cannot overlap; a later Stop need not wait for an old
36
+ // watcher or a slow late settlement to release a session-wide lock.
37
+ const coordinate = [input.session_id, route.address.endpoint ?? home, captured.generation, turnId];
29
38
  const root = path.join(path.dirname(deliveryLedgerLocation(env)), 'codex-watchers');
30
39
  await fs.mkdir(root, { recursive: true, mode: 0o700 });
31
40
  const lock = path.join(root, createHash('sha256').update(JSON.stringify(coordinate)).digest('hex') + '.lock');
32
41
  const file = await fs.open(lock, 'a', 0o600);
33
42
  await file.close();
34
43
  await withFileLock(lock, { retryMs: 10, skipIfBusy: true, signal: combined }, async () => {
35
- const ledger = await DeliveryLedger.open({ location: deliveryLedgerLocation(env) });
36
- const lifetime = retainDeliveryLedger(ledger);
37
- let observer;
44
+ const observer = await ledger.observe(owner);
38
45
  try {
39
- const owner = harnessOwner({ channel: 'codex', session: input.session_id });
40
- const captured = await ledger.session(owner);
41
- if (captured?.state !== 'attached')
42
- return;
43
- const boundaries = codexBoundaryStore(env);
44
- // Subscribe before inspecting pending work. Both ledger edges and the
45
- // bounded wait below cause reconciliation; boundary-only changes matter.
46
- observer = await ledger.observe(owner);
47
46
  while (!combined.aborted) {
48
- let version = await observer.read(combined);
47
+ const version = await observer.read(combined);
49
48
  const session = await ledger.session(owner);
50
49
  if (session?.state !== 'attached' || session.generation !== captured.generation)
51
50
  return;
@@ -54,36 +53,34 @@ export async function runCodexWatch(inputText, env = process.env, signal) {
54
53
  registered.address.endpoint !== route.address.endpoint ||
55
54
  !registered.address.codexHome || await canonicalPath(registered.address.codexHome) !== home)
56
55
  return;
57
- const boundary = await boundaries.read(input.session_id);
58
- if (boundary?.ended)
56
+ // Probe even with no pending card: a new user/model turn retires this
57
+ // watcher without requiring UserPromptSubmit or tool hooks to disarm it.
58
+ const state = await codexStopState({ harness: 'codex', sessionId: input.session_id,
59
+ codexHome: registered.address.codexHome, endpoint: registered.address.endpoint,
60
+ timeoutMs: 2000, signal: combined }, turnId);
61
+ if (state === 'stale')
59
62
  return;
60
- const pending = await ledger.pending(owner, { generation: captured.generation });
61
- const held = new Set((await ledger.handoffs(owner, { generation: captured.generation }))
62
- .filter(attempt => !['failed', 'released'].includes(attempt.outcome)).map(attempt => attempt.seq));
63
- for (const card of pending) {
64
- if (combined.aborted)
65
- break;
66
- if (held.has(card.seq))
67
- continue;
68
- // Follow the latest Stop, regardless of which Stop launched this process.
69
- const epoch = (await boundaries.read(input.session_id))?.stop;
70
- if (!epoch || !await codexIdleAtStop({ harness: 'codex', sessionId: input.session_id,
71
- codexHome: registered.address.codexHome, endpoint: registered.address.endpoint,
72
- timeoutMs: 2000, signal: combined }, epoch.turnId))
73
- break;
74
- lifetime.track(await ledger.dispatchHandoff(owner, card.key, new CodexQueueTransport({ epoch, env, address: registered.address }), { generation: captured.generation, timeoutMs: 5000, signal: combined }));
75
- // Drain our own claim/settlement edge. A known-unsent failure must
76
- // not turn file observation into an immediate retry loop. Any post
77
- // racing this read is still found by the bounded rescan below.
78
- version = await observer.read(combined);
63
+ if (state === 'unavailable') {
64
+ if (!combined.aborted)
65
+ console.warn('Square Codex idle delivery unavailable: requires a reachable hosting app-server endpoint and supported native turn history. PostToolUse delivery remains available.');
66
+ return;
67
+ }
68
+ if (state === 'idle') {
69
+ const held = new Set((await ledger.handoffs(owner, { generation: captured.generation }))
70
+ .filter(attempt => !['failed', 'released'].includes(attempt.outcome)).map(attempt => attempt.seq));
71
+ const card = (await ledger.pending(owner, { generation: captured.generation })).find(card => !held.has(card.seq));
72
+ if (card) {
73
+ lifetime.track(await ledger.dispatchHandoff(owner, card.key, new CodexQueueTransport({ turnId, env, address: registered.address }), { generation: captured.generation, timeoutMs: 5000, signal: combined }));
74
+ // One handoff attempt ends this wait, including unknown/refused
75
+ // outcomes. Never replay or turn our own writes into a retry loop.
76
+ return;
77
+ }
79
78
  }
80
- // No claims are held while waiting, including while active or disarmed.
81
79
  await observer.changed(version, 1000, combined);
82
80
  }
83
81
  }
84
82
  finally {
85
- observer?.close();
86
- await lifetime.close();
83
+ observer.close();
87
84
  }
88
85
  });
89
86
  }
@@ -95,6 +92,7 @@ export async function runCodexWatch(inputText, env = process.env, signal) {
95
92
  clearTimeout(timer);
96
93
  process.off('SIGTERM', stop);
97
94
  process.off('SIGINT', stop);
95
+ await lifetime?.close();
98
96
  }
99
97
  return '';
100
98
  }
@@ -1,6 +1,10 @@
1
1
  import type { CodexAppServerConnectOptions, CodexDeliveryResult, CodexSendTextOptions, CodexTarget } from './types.js';
2
+ /** A Stop can launch before its native turn has finished settling. Never
3
+ * mistake that short wait for authority to follow a different turn. */
4
+ export type CodexStopState = 'idle' | 'waiting' | 'stale' | 'unavailable';
2
5
  /** Newest-last live history is verified for Codex 0.166.0 only. A sampled read
3
6
  * cannot make a later external queue atomic with native turn starts. */
7
+ export declare function codexStopState(options: CodexAppServerConnectOptions, turnId: string): Promise<CodexStopState>;
4
8
  export declare function codexIdleAtStop(options: CodexAppServerConnectOptions, turnId: string): Promise<boolean>;
5
9
  /** Address an existing thread on an already running app-server. Never starts a
6
10
  * service, resumes a thread, or claims ownership of a standalone CLI session. */
@@ -253,37 +253,42 @@ function validatedConnection(options) {
253
253
  }
254
254
  /** Newest-last live history is verified for Codex 0.166.0 only. A sampled read
255
255
  * cannot make a later external queue atomic with native turn starts. */
256
- export async function codexIdleAtStop(options, turnId) {
256
+ export async function codexStopState(options, turnId) {
257
257
  const connection = validatedConnection(options);
258
258
  if (!isIdentity(turnId))
259
- return false;
259
+ return 'unavailable';
260
260
  const scope = deadline(options.timeoutMs, options.signal);
261
261
  let peer;
262
262
  try {
263
263
  peer = await open(connection, scope);
264
264
  const version = await peer.initialize(scope);
265
- // The app-server's originator prefix belongs to the hosting client, which
266
- // may have initialized before this connection. Fence the native version.
267
265
  if (!/^[A-Za-z0-9._-]+\/0\.166\.0(?:\s|$)/.test(version))
268
- return false;
266
+ return 'unavailable';
269
267
  const thread = await readThread(peer, options.sessionId, scope, true);
270
- if (!isRecord(thread.status) || thread.status.type !== 'idle' || !Array.isArray(thread.turns) || !thread.turns.length)
271
- return false;
272
- // Validate the whole live list, including absence of an active turn; never
273
- // fall back to persisted pages or a boolean boundary for missing history.
274
- if (activeTurn(thread.turns) !== undefined || new Set(thread.turns.map(turn => turn.id)).size !== thread.turns.length)
275
- return false;
268
+ if (!isRecord(thread.status) || !['idle', 'active'].includes(thread.status.type)
269
+ || !Array.isArray(thread.turns) || !thread.turns.length)
270
+ return 'unavailable';
271
+ const active = activeTurn(thread.turns);
272
+ if (new Set(thread.turns.map(turn => turn.id)).size !== thread.turns.length)
273
+ return 'unavailable';
276
274
  const newest = thread.turns.at(-1);
277
- return newest.id === turnId && newest.status === 'completed';
275
+ if (newest.id !== turnId || ['interrupted', 'failed'].includes(newest.status))
276
+ return 'stale';
277
+ if (active !== undefined && active !== turnId)
278
+ return 'unavailable';
279
+ return newest.status === 'completed' && active === undefined && thread.status.type === 'idle' ? 'idle' : 'waiting';
278
280
  }
279
281
  catch {
280
- return false;
282
+ return 'unavailable';
281
283
  }
282
284
  finally {
283
285
  scope.close();
284
286
  await peer?.close();
285
287
  }
286
288
  }
289
+ export async function codexIdleAtStop(options, turnId) {
290
+ return await codexStopState(options, turnId) === 'idle';
291
+ }
287
292
  /** Address an existing thread on an already running app-server. Never starts a
288
293
  * service, resumes a thread, or claims ownership of a standalone CLI session. */
289
294
  export async function connectCodex(options) {
@@ -2,12 +2,12 @@
2
2
 
3
3
  The Square Claude plugin uses ordinary native command hooks. It has no mod, native inbox, or producer socket route. `SessionStart` attaches the exact native session to the host delivery ledger; `SessionEnd` fences and detaches that captured attachment without changing Square membership.
4
4
 
5
- At `UserPromptSubmit`, Square records Claude's native `prompt_id` before presenting pending cards through event-correct `additionalContext`. Tool boundaries (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`) use the same boundary presenter for the exact native main/child owner, so cards arriving during a running turn need not wait for another user prompt. Autonomous hook-generated prompts are boundaries too, not evidence of human approval. A later prompt records presentation only when its input contains the exact complete prepared frame, including the ledger token and stored full-frame hash. That establishes observation before the model processes the prompt, not comprehension. The async frame uses a plain warning and token delimiters; Square never decodes entities or accepts changed/truncated text as an exact observation.
5
+ At `UserPromptSubmit`, Square records Claude's native `prompt_id` before presenting pending cards through event-correct `additionalContext`. `PostToolUse` is the single tool-boundary presenter for the exact native main/child owner, so cards arriving during a running turn can be delivered after a successful tool completes. `PreToolUse` and `PostToolUseFailure` match only Square MCP tools and handle identity publication/cleanup, not presentation. A failed ordinary tool leaves cards for the next PostToolUse, UserPromptSubmit or normal Stop. Autonomous hook-generated prompts are boundaries too, not evidence of human approval. A later prompt records presentation only when its input contains the exact complete prepared frame, including the ledger token and stored full-frame hash. That establishes observation before the model processes the prompt, not comprehension. The async frame uses a plain warning and token delimiters; Square never decodes entities or accepts changed/truncated text as an exact observation.
6
6
 
7
7
  At a normal `Stop`, one per-owner OS-locked observer waits for ledger changes for at most 86,400 seconds. It rechecks the exact captured `prompt_id`, attachment generation, Stop epoch and route after acquiring the lock and immediately before output. A new prompt disarms old observers. The watcher claims one card at a time and uses the existing handoff token/hash correlation. Invoked stderr output is `unknown` until that exact frame is observed at a later prompt boundary; there is no automatic fallback replay. Missing prompt IDs fail closed.
8
8
 
9
9
  This is a finite, boundary-driven observer, not a promise of continuous coverage. Missing Stop, timeout, process exit, headless teardown, or a busy Escape with no later normal Stop may leave cards pending. Idle Escape has no classic hook and may not disarm an already waiting observer; already emitted asyncRewake notifications cannot be recalled. No Square inbox fallback is retained for these cases.
10
10
 
11
- The bundled MCP server gets call-local session, cwd, and agent attribution from native `PreToolUse`/terminal tool hooks keyed by Claude's `claudecode/toolUseId` metadata. Duplicate or incomplete call identity is denied by the event-specific PreToolUse permission response; unrelated tool hooks do not block. Cleanup matches tool-use ID, session and agent. Interrupted calls without a terminal hook expire after ten minutes. Models cannot supply identity as tool arguments.
11
+ The bundled MCP server gets call-local session, cwd, and agent attribution from native `PreToolUse`/terminal tool hooks keyed by Claude's `claudecode/toolUseId` metadata. The manifest scopes `PreToolUse` and `PostToolUseFailure` to `^mcp__plugin_square_square__.*`, while `PostToolUse` remains unfiltered for delivery and successful MCP cleanup. An ordinary successful tool therefore launches only PostToolUse, not both a pre- and post-tool command. Duplicate or incomplete call identity is denied by the event-specific PreToolUse permission response; unrelated tool hooks do not block. Cleanup matches tool-use ID, session and agent. Interrupted calls without a terminal hook expire after ten minutes. Models cannot supply identity as tool arguments.
12
12
 
13
13
  The installed and tested native runtime evidence is macOS Claude Code 2.1.299 with isolated HOME, plugin settings, delivery ledger and a dummy-key loopback provider. This does not certify other platforms, versions, or headless behavior. The final native prompt/generation check and async hook completion are not atomic. `node --import ./test/sandbox-env.js test/claude-hook-native.mjs` with `SQUARE_CLAUDE_HOOK_NATIVE_SMOKE=1` runs the shipped manifest and candidate CLI through a scratch PATH shim and preserves hook/provider evidence in `/tmp`. It proves repeated idle wake, prompt/attachment fencing, duplicate suppression, active prompt/tool context, native main/child MCP attribution and identity-failure denial.
@@ -1,67 +1,96 @@
1
1
  # Codex delivery
2
2
 
3
3
  Producers post neutral cards to DeliveryLedger; neither default dispatch nor public `postAndDispatch`
4
- queues Codex input. A native receiver owns both synchronous presentation and idle queue admission.
5
- It never reads Square artifacts or producer content stores.
6
-
7
- The shipped hooks for Codex 0.166.0 are synchronous SessionStart, UserPromptSubmit, PreToolUse,
8
- PostToolUse, Stop, Interrupt and SessionEnd, plus an async Stop command with `timeout: 86400` seconds.
9
- SessionStart disarms and attaches the exact native session. Active prompt/tool boundaries disarm and
10
- present eligible cards as event-correct `additionalContext`, using neutral reminder framing and the
11
- original card text. The output sink must accept the response before presentation evidence is recorded;
12
- a later suppression does not erase a real output effect. SessionEnd disarms and detaches. None of these
13
- host lifecycle events restores or detaches Square seats. SessionResume is not a verified native event
14
- and is not registered. A resume path without a real SessionStart/Stop has no inferred idle authority.
15
-
16
- Each synchronous Stop stores a unique token and native turn ID in the existing boundary store. One
17
- receiver-owned `square codex-watch` command holds a separate private OS-backed lock for its native
18
- session coordinate and observes the ledger, with a one-second bounded rescan for boundary changes or
19
- missed notifications. Duplicate async Stop processes exit; the existing watcher follows newer Stop
20
- tokens. The lock does not prove idle state. Observation never reserves cards while waiting.
21
-
22
- Before claiming each card, the watcher reads the hosting endpoint's `thread/read(includeTurns:true)`.
23
- It requires loaded idle state and the newest live turn to be completed with exactly the captured Stop
24
- turn ID. Newest-last history ordering is verified for 0.166.0; other versions fail closed until verified.
25
- After claiming, the receiver repeats that native read, checks the exact epoch again, then checks ledger
26
- currentness, suppression, cancellation, attachment generation, signal and deadline immediately before
27
- CLI invocation. Queue always uses the existing hosting endpoint explicitly, without starting a service.
28
- Missing, malformed, ephemeral, notLoaded, active, interrupted or unreadable history cannot authorize a
29
- queue. A delayed stale Stop cannot authorize sending merely by arriving after an active callback.
30
-
31
- `square codex-interrupt` handles only a native Interrupt payload and cancels the captured attachment's
32
- pending through-sequence batch, then future cards remain available after a later verified Stop or active
33
- boundary. Replacement attachments are fenced by generation. Before-effect epoch/suppression/cancel
34
- failures release their claims where the ledger permits. Attachment rotation already normalizes an
35
- in-flight ledger attempt to unknown: that custody remains unknown even if the final receiver check
36
- then proves no queue was launched. The watcher exits on detach, deletion or generation change and
37
- retains its ledger connection only for already-captured late settlements.
38
-
39
- Queue admission is custody, not presentation or model comprehension. Accepted/unknown outbox custody
40
- has no remaining card content and cannot be automatically replayed. Invoked timeout or ambiguous
41
- results remain unknown. Native idle reads and external queue are not atomic: a new turn can start after
42
- the final check and a raced queue may arrive later. Interrupt cannot recall input already admitted by
43
- Codex, and hook failure/timeout cannot prove that cancellation happened. Native callbacks have no
44
- cross-incarnation event identity; SessionEnd can fence only the attachment observed when it runs.
45
-
46
- There is no watcher before the first Stop. Synchronous boundaries can present; otherwise cards wait.
47
- The async budget is finite 24 hours, not a promise of 24-hour observation. After timeout, crash, host
48
- termination or missed spawn while idle, cards wait for the next Stop/user/native boundary. There is no
49
- producer fallback or autonomous crash-idle recovery. During attachment replacement an old watcher
50
- may briefly retain the coordinate lock while the new Stop's duplicate exits; the next Stop restores
51
- observation after the old process releases it.
52
-
53
- Reproduce the isolated native smoke from this checkout after a worktree-local build:
4
+ queues Codex input. The receiver owns synchronous presentation and idle queue admission, without
5
+ reading Square artifacts or producer content stores.
6
+
7
+ ## Five hooks
8
+
9
+ The Codex 0.166.0 manifest registers one command for each of five events:
10
+
11
+ - SessionStart attaches the exact native session and registers its queue address.
12
+ - PostToolUse synchronously presents eligible cards as event-correct `additionalContext`.
13
+ - Stop runs one async `square codex-watch` with a maximum timeout of 86,400 seconds.
14
+ - Interrupt cancels the captured attachment's current pending through-sequence batch.
15
+ - SessionEnd detaches the captured attachment.
16
+
17
+ There is no synchronous Stop command, UserPromptSubmit hook, or PreToolUse hook. Active delivery
18
+ occurs after a tool finishes; a model-only turn with no tools receives pending cards after its next
19
+ Stop through the queue path. Host lifecycle never restores or detaches Square seats.
20
+
21
+ PostToolUse records presentation only after its output sink accepts the response. A later suppression
22
+ does not erase that output effect. This is observer-level evidence, not proof of model comprehension.
23
+
24
+ ## One Stop, one wait
25
+
26
+ Each async Stop captures its payload's native turn ID, ledger attachment generation and queue address.
27
+ A private OS-backed lock for that coordinate, generation and turn deduplicates overlapping callbacks.
28
+ A different Stop or replacement attachment does not wait for the old watcher's lock. The watcher
29
+ never adopts a newer Stop and never starts its own successor; Codex starts the next watcher when the
30
+ next turn stops.
31
+
32
+ On every reconciliation, even with no pending cards, the watcher reads the hosting endpoint's
33
+ `thread/read(includeTurns:true)`. Only a loaded idle thread whose newest completed turn exactly
34
+ matches the captured ID may authorize queueing. The same turn still settling may wait; a newer,
35
+ interrupted or failed turn retires the watcher. Unreadable, malformed, notLoaded or unsupported history
36
+ ends idle observation with a bounded diagnostic. Newest-last history is verified for 0.166.0 only.
37
+ There is no second persisted root Stop-authority store for delayed callbacks to overwrite.
38
+
39
+ The watcher subscribes before reading the ledger, uses a one-second bounded rescan, and holds no
40
+ card claims while waiting. On an eligible card it claims once, repeats the native idle check, and
41
+ checks ledger currentness, suppression, cancellation, attachment generation, signal and deadline
42
+ immediately before queue invocation. It then ends observation after that handoff attempt, whether
43
+ accepted, unknown or definitely unsent. A refusal is not retried in the same wait. Failed/released
44
+ cards remain available to PostToolUse or a future Stop; accepted/unknown custody cannot be replayed.
45
+ The original ledger connection is retained only for already-invoked late settlements.
46
+
47
+ The lock deduplicates overlapping Stop callbacks, not all sequential duplicates forever. Ledger
48
+ custody excludes replay of the same card; it is not a persistent one-send-per-turn budget for different
49
+ cards. A duplicate callback arriving after the first watcher exits could submit another card before
50
+ native history reflects the queued turn.
51
+
52
+ ## Transport and cancellation limits
53
+
54
+ Idle queueing requires an externally reachable endpoint for the app-server already hosting the
55
+ thread: `SQUARE_CODEX_ENDPOINT`, or the control socket under that receiver's `CODEX_HOME`. Queue uses
56
+ that endpoint explicitly and never starts a replacement service. A private stdio-only app-server
57
+ without a control endpoint cannot be reached by a new process. In that case the async watcher exits;
58
+ PostToolUse still presents cards through the native hook without any app-server connection.
59
+ A completed async hook's own output does not wake an idle Codex turn.
60
+
61
+ Queue admission is custody, not presentation. Accepted/unknown outbox custody has no remaining card
62
+ content. Invoked timeout or ambiguous results remain unknown; never automatically replay them.
63
+ Native reads and external queue are not atomic: a user turn or interruption can race after the final
64
+ check, and admitted input cannot be recalled.
65
+
66
+ Interrupt cancellation suppresses the captured attachment's pending batch, not future cards. A native
67
+ interrupted turn retires its watcher; future cards can be received at PostToolUse or a later Stop.
68
+ The native smoke exercises active `turn/interrupt`, not physical TUI Escape or idle cancellation paths.
69
+ Hook payloads do not provide a cross-incarnation event identity: generation capture protects against
70
+ replacement after capture, not a callback that was already stale when it began. In particular, a late
71
+ Interrupt can still cancel a newer batch in the same attachment. SessionEnd has the same observation-
72
+ time capture limitation.
73
+
74
+ Observation is finite. Before the first Stop, after timeout/crash/missed spawn, or after an unsent
75
+ handoff failure, cards wait for PostToolUse or a subsequent Stop. There is no detached helper,
76
+ producer fallback or autonomous crash-idle recovery.
77
+
78
+ ## Native evidence
79
+
80
+ Reproduce the isolated native smoke after a worktree-local build:
54
81
 
55
82
  ```sh
56
83
  env -u SQUARE_HOST_LEDGER_ROOT SQUARE_CODEX_NATIVE_SMOKE=1 \
57
84
  node --import ./test/sandbox-env.js test/codex-watch-native.mjs
58
85
  ```
59
86
 
60
- The probe creates a temporary HOME/CODEX_HOME, installs no plugin, and uses the actual shipped hook
61
- manifest/CLI with an isolated native app-server and loopback fixture provider. Its JSON evidence names
62
- the temporary directory. It verifies native Stop startup, idle wake, busy fencing, singleton reuse,
63
- Interrupt RPC cancellation, real UserPromptSubmit presentation and process-group shutdown. Physical
64
- TUI Escape-to-Interrupt is not verified; neither are 24-hour operation or every resume path.
87
+ The probe uses a temporary HOME/CODEX_HOME, actual shipped hooks/CLI, an isolated native app-server
88
+ with an explicit Unix endpoint, and a loopback fixture provider. Its JSON evidence names the temporary
89
+ directory. It verifies Stop startup, one-shot idle wake and exit, fresh watchers for new Stops, busy
90
+ fencing with no pending cards, Interrupt RPC cancellation, future delivery, real PostToolUse context
91
+ and receipts, and termination of a still-waiting watcher at host shutdown.
92
+ It does not certify stdio-only idle wake, physical TUI Escape, 24-hour lifetime or every resume path.
65
93
 
66
- The shared package documents its transport leaves in [its README](../packages/agent-delivery/README.md).
67
- See [participant ownership](participant-ownership.md) for host attachment and Square membership.
94
+ The shared package's separate public boundary/transport helpers remain documented in
95
+ [its README](../packages/agent-delivery/README.md). See [participant ownership](participant-ownership.md)
96
+ for host attachment and Square membership.
@@ -43,6 +43,7 @@ idle connection until process exit. Late persistence failures produce redacted d
43
43
  reopening the ledger or retrying the effect. Pi presentation and Claude's independent exact-reference
44
44
  confirmation callbacks retain their existing lifetimes.
45
45
 
46
- Codex producers only post. Its native async Stop receiver observes the ledger without holding claims
47
- while waiting; active UserPromptSubmit, PreToolUse and PostToolUse boundaries present synchronously.
48
- See [Codex delivery](codex-delivery.md) for exact idle-epoch fencing, cancellation and recovery limits.
46
+ Codex producers only post. Each native async Stop receiver waits without holding claims, attempts one
47
+ idle queue handoff, then exits; PostToolUse presents synchronously during execution. Idle queueing
48
+ requires a reachable hosting app-server endpoint, not private stdio alone. See
49
+ [Codex delivery](codex-delivery.md) for native turn fencing, cancellation and recovery limits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Native Claude Code hook delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -3,9 +3,9 @@
3
3
  "SessionStart": [{ "hooks": [{ "type": "command", "command": "square claude-hook", "timeout": 5 }] }],
4
4
  "SessionEnd": [{ "hooks": [{ "type": "command", "command": "square claude-hook", "timeout": 3 }] }],
5
5
  "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "square claude-hook", "timeout": 5 }] }],
6
- "PreToolUse": [{ "hooks": [{ "type": "command", "command": "square claude-hook", "timeout": 5 }] }],
6
+ "PreToolUse": [{ "matcher": "^mcp__plugin_square_square__.*", "hooks": [{ "type": "command", "command": "square claude-hook", "timeout": 5 }] }],
7
7
  "PostToolUse": [{ "hooks": [{ "type": "command", "command": "square claude-hook", "timeout": 5 }] }],
8
- "PostToolUseFailure": [{ "hooks": [{ "type": "command", "command": "square claude-hook", "timeout": 5 }] }],
8
+ "PostToolUseFailure": [{ "matcher": "^mcp__plugin_square_square__.*", "hooks": [{ "type": "command", "command": "square claude-hook", "timeout": 5 }] }],
9
9
  "Stop": [{ "hooks": [{ "type": "command", "command": "square claude-watch", "asyncRewake": true, "timeout": 86400 }] }]
10
10
  }
11
11
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -6,20 +6,11 @@
6
6
  "SessionEnd": [
7
7
  { "hooks": [{ "type": "command", "command": "square codex-hook", "timeout": 3, "statusMessage": "Square" }] }
8
8
  ],
9
- "UserPromptSubmit": [
10
- { "hooks": [{ "type": "command", "command": "square codex-hook", "timeout": 5, "statusMessage": "Square" }] }
11
- ],
12
- "PreToolUse": [
13
- { "hooks": [{ "type": "command", "command": "square codex-hook", "timeout": 5, "statusMessage": "Square" }] }
14
- ],
15
9
  "PostToolUse": [
16
10
  { "hooks": [{ "type": "command", "command": "square codex-hook", "timeout": 5, "statusMessage": "Square" }] }
17
11
  ],
18
12
  "Stop": [
19
- { "hooks": [
20
- { "type": "command", "command": "square codex-hook", "timeout": 5, "statusMessage": "Square" },
21
- { "type": "command", "command": "square codex-watch", "timeout": 86400, "async": true, "statusMessage": "Square" }
22
- ] }
13
+ { "hooks": [{ "type": "command", "command": "square codex-watch", "timeout": 86400, "async": true, "statusMessage": "Square" }] }
23
14
  ],
24
15
  "Interrupt": [
25
16
  { "hooks": [{ "type": "command", "command": "square codex-interrupt", "timeout": 3, "statusMessage": "Square" }] }
@@ -1,2 +0,0 @@
1
- export type { CodexBoundary, CodexStopEpoch } from './packages/agent-delivery/src/codex-boundary.js';
2
- export declare function codexBoundaryStore(env?: NodeJS.ProcessEnv): import("./packages/agent-delivery/src/codex-boundary.js").CodexBoundaryStore;
@@ -1,6 +0,0 @@
1
- import os from 'node:os';
2
- import path from 'node:path';
3
- import { createCodexBoundaryStore } from './packages/agent-delivery/src/codex-boundary.js';
4
- export function codexBoundaryStore(env = process.env) {
5
- return createCodexBoundaryStore(env.SQUARE_CODEX_BOUNDARIES || path.join(os.homedir(), '.square', 'codex-boundaries.json'));
6
- }