@astrosheep/square 0.4.0 → 0.4.2

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 (196) hide show
  1. package/dist/artifact.d.ts +1 -3
  2. package/dist/artifact.js +12 -36
  3. package/dist/attention-presentation.d.ts +2 -22
  4. package/dist/attention-presentation.js +9 -12
  5. package/dist/boundary-presentation.d.ts +3 -14
  6. package/dist/boundary-presentation.js +29 -104
  7. package/dist/card-presentation.d.ts +1 -0
  8. package/dist/card-presentation.js +2 -0
  9. package/dist/catch-decisions.js +1 -7
  10. package/dist/claude-boundary-state.d.ts +20 -0
  11. package/dist/claude-boundary-state.js +84 -0
  12. package/dist/claude-hook.d.ts +2 -0
  13. package/dist/claude-hook.js +231 -0
  14. package/dist/claude-mcp-context.d.ts +1 -1
  15. package/dist/claude-mcp-context.js +12 -3
  16. package/dist/cli/context.js +7 -9
  17. package/dist/cli/observation-commands.d.ts +4 -1
  18. package/dist/cli/observation-commands.js +37 -4
  19. package/dist/cli/registry.js +5 -2
  20. package/dist/codex-address.d.ts +2 -2
  21. package/dist/codex-boundary-state.d.ts +1 -5
  22. package/dist/codex-boundary-state.js +0 -4
  23. package/dist/codex-hook.d.ts +7 -10
  24. package/dist/codex-hook.js +69 -33
  25. package/dist/codex-queue.d.ts +10 -11
  26. package/dist/codex-queue.js +32 -32
  27. package/dist/codex-watch.d.ts +2 -0
  28. package/dist/codex-watch.js +100 -0
  29. package/dist/decisions.js +2 -2
  30. package/dist/delivery-deferral.d.ts +8 -17
  31. package/dist/delivery-deferral.js +0 -28
  32. package/dist/delivery-frame.d.ts +7 -0
  33. package/dist/delivery-frame.js +11 -0
  34. package/dist/delivery-ledger-sqlite.d.ts +97 -0
  35. package/dist/delivery-ledger-sqlite.js +496 -0
  36. package/dist/delivery-ledger.d.ts +196 -0
  37. package/dist/delivery-ledger.js +268 -0
  38. package/dist/delivery-lifetime.d.ts +6 -0
  39. package/dist/delivery-lifetime.js +45 -0
  40. package/dist/harness-claude.js +3 -4
  41. package/dist/harness-links.js +63 -7
  42. package/dist/harness.js +12 -17
  43. package/dist/help.js +4 -1
  44. package/dist/http-delivery.d.ts +5 -9
  45. package/dist/http-delivery.js +20 -77
  46. package/dist/inbox.d.ts +6 -49
  47. package/dist/inbox.js +24 -371
  48. package/dist/index.d.ts +6 -3
  49. package/dist/index.js +4 -1
  50. package/dist/mcp.js +2 -3
  51. package/dist/model.d.ts +7 -47
  52. package/dist/model.js +1 -7
  53. package/dist/notifications.d.ts +24 -20
  54. package/dist/notifications.js +39 -108
  55. package/dist/operation-control.d.ts +4 -0
  56. package/dist/operation-control.js +20 -0
  57. package/dist/packages/agent-delivery/src/claude-native.d.ts +2 -0
  58. package/dist/packages/agent-delivery/src/claude-native.js +5 -0
  59. package/dist/packages/agent-delivery/src/codex-boundary.d.ts +11 -6
  60. package/dist/packages/agent-delivery/src/codex-boundary.js +31 -40
  61. package/dist/packages/agent-delivery/src/codex-hook.d.ts +17 -12
  62. package/dist/packages/agent-delivery/src/codex-hook.js +26 -39
  63. package/dist/packages/agent-delivery/src/codex.d.ts +3 -0
  64. package/dist/packages/agent-delivery/src/codex.js +41 -4
  65. package/dist/packages/agent-delivery/src/index.d.ts +3 -1
  66. package/dist/packages/agent-delivery/src/index.js +1 -1
  67. package/dist/packages/agent-delivery/src/paseo.js +11 -2
  68. package/dist/packages/agent-delivery/src/types.d.ts +1 -0
  69. package/dist/paseo-delivery.d.ts +5 -5
  70. package/dist/paseo-delivery.js +19 -97
  71. package/dist/paseo.d.ts +1 -1
  72. package/dist/paseo.js +1 -1
  73. package/dist/plugins/opencode/index.d.ts +5 -3
  74. package/dist/plugins/opencode/index.js +99 -21
  75. package/dist/plugins/opencode-shared/delivery.d.ts +23 -0
  76. package/dist/plugins/opencode-shared/delivery.js +109 -0
  77. package/dist/plugins/opencode-v1/index.d.ts +28 -0
  78. package/dist/plugins/opencode-v1/index.js +41 -0
  79. package/dist/plugins/opencode-v1/runtime.d.ts +56 -0
  80. package/dist/plugins/opencode-v1/runtime.js +152 -0
  81. package/dist/plugins/opencode-v1/transport.d.ts +13 -0
  82. package/dist/plugins/opencode-v1/transport.js +50 -0
  83. package/dist/plugins/paseo/shared/card-presentation.d.ts +10 -0
  84. package/dist/plugins/paseo/shared/card-presentation.js +7 -0
  85. package/dist/ports.d.ts +31 -90
  86. package/dist/session-lifecycle.d.ts +8 -7
  87. package/dist/session-lifecycle.js +40 -42
  88. package/dist/sqlite-square.d.ts +14 -23
  89. package/dist/sqlite-square.js +132 -139
  90. package/dist/square-api.js +4 -7
  91. package/dist/square-core.d.ts +0 -1
  92. package/dist/square-domain.d.ts +5 -17
  93. package/dist/square-domain.js +104 -221
  94. package/dist/square-facade.d.ts +18 -15
  95. package/dist/square-post.d.ts +53 -0
  96. package/dist/square-post.js +4 -0
  97. package/dist/square-posting.d.ts +13 -0
  98. package/dist/square-posting.js +174 -0
  99. package/dist/square-producer-sqlite.d.ts +11 -0
  100. package/dist/square-producer-sqlite.js +210 -0
  101. package/dist/square-sqlite.d.ts +15 -25
  102. package/dist/square-sqlite.js +150 -226
  103. package/dist/views.d.ts +2 -2
  104. package/dist/views.js +0 -1
  105. package/dist/wake-sink.d.ts +4 -11
  106. package/dist/wake-sink.js +8 -32
  107. package/dist/wake-transports.d.ts +4 -7
  108. package/dist/wake-transports.js +7 -39
  109. package/examples/symmetric-domains.mjs +29 -249
  110. package/guides/change-waits.md +13 -53
  111. package/guides/claude-native.md +7 -171
  112. package/guides/codex-delivery.md +55 -80
  113. package/guides/delivery-primitives.md +48 -207
  114. package/guides/mcp.md +1 -1
  115. package/guides/operation-cancellation.md +2 -5
  116. package/guides/participant-ownership.md +25 -89
  117. package/guides/pi-native-delivery.md +19 -56
  118. package/guides/pi-tools.md +6 -8
  119. package/guides/square-api.md +113 -615
  120. package/package.json +8 -8
  121. package/plugins/claude/.claude-plugin/plugin.json +2 -3
  122. package/plugins/claude/hooks/hooks.json +11 -1
  123. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  124. package/plugins/codex/hooks/hooks.json +11 -23
  125. package/plugins/opencode/index.ts +91 -23
  126. package/plugins/opencode-shared/delivery.ts +91 -0
  127. package/plugins/opencode-v1/index.ts +36 -0
  128. package/plugins/opencode-v1/runtime.ts +134 -0
  129. package/plugins/opencode-v1/transport.ts +41 -0
  130. package/plugins/paseo/OVERVIEW.md +5 -5
  131. package/plugins/paseo/README.md +7 -6
  132. package/plugins/paseo/client/markdown.tsx +5 -5
  133. package/plugins/paseo/client/square-message.tsx +28 -24
  134. package/plugins/paseo/client/transform.ts +8 -5
  135. package/plugins/paseo/shared/card-presentation.ts +16 -0
  136. package/plugins/paseo/shared/message.ts +6 -11
  137. package/plugins/pi/square-pi.js +156 -417
  138. package/dist/channel-artifact.d.ts +0 -10
  139. package/dist/channel-artifact.js +0 -55
  140. package/dist/channel-delivery.d.ts +0 -11
  141. package/dist/channel-delivery.js +0 -51
  142. package/dist/channel-domain.d.ts +0 -3
  143. package/dist/channel-domain.js +0 -83
  144. package/dist/channel-facade.d.ts +0 -76
  145. package/dist/channel-facade.js +0 -1
  146. package/dist/channel-internal.d.ts +0 -126
  147. package/dist/channel-internal.js +0 -440
  148. package/dist/channel-metadata.d.ts +0 -11
  149. package/dist/channel-metadata.js +0 -1
  150. package/dist/channel-ports.d.ts +0 -241
  151. package/dist/channel-ports.js +0 -1
  152. package/dist/channel-presentation.d.ts +0 -17
  153. package/dist/channel-presentation.js +0 -22
  154. package/dist/channel-sqlite-schema.d.ts +0 -13
  155. package/dist/channel-sqlite-schema.js +0 -81
  156. package/dist/channel-transport.d.ts +0 -62
  157. package/dist/channel-transport.js +0 -1
  158. package/dist/channel.d.ts +0 -84
  159. package/dist/channel.js +0 -60
  160. package/dist/claude-delivery-status.d.ts +0 -13
  161. package/dist/claude-delivery-status.js +0 -46
  162. package/dist/claude-delivery.d.ts +0 -12
  163. package/dist/claude-delivery.js +0 -108
  164. package/dist/claude-mod.d.ts +0 -8
  165. package/dist/claude-mod.js +0 -172
  166. package/dist/claude-transport.d.ts +0 -5
  167. package/dist/claude-transport.js +0 -58
  168. package/dist/cmd/notify-once.d.ts +0 -2
  169. package/dist/cmd/notify-once.js +0 -29
  170. package/dist/delivery-attempts.d.ts +0 -52
  171. package/dist/delivery-attempts.js +0 -73
  172. package/dist/delivery-effects.d.ts +0 -139
  173. package/dist/delivery-effects.js +0 -163
  174. package/dist/delivery-health.d.ts +0 -51
  175. package/dist/delivery-health.js +0 -173
  176. package/dist/delivery-operations.d.ts +0 -22
  177. package/dist/delivery-operations.js +0 -198
  178. package/dist/delivery-primitives.d.ts +0 -5
  179. package/dist/delivery-primitives.js +0 -3
  180. package/dist/delivery.d.ts +0 -56
  181. package/dist/delivery.js +0 -80
  182. package/dist/plugins/opencode/delivery.d.ts +0 -19
  183. package/dist/plugins/opencode/delivery.js +0 -223
  184. package/dist/presentation-operations.d.ts +0 -5
  185. package/dist/presentation-operations.js +0 -86
  186. package/dist/sqlite-channel.d.ts +0 -15
  187. package/dist/sqlite-channel.js +0 -600
  188. package/dist/square-index.d.ts +0 -16
  189. package/dist/square-index.js +0 -106
  190. package/dist/wake-preview.d.ts +0 -7
  191. package/dist/wake-preview.js +0 -15
  192. package/plugins/claude/hooks/register.js +0 -132
  193. package/plugins/claude/types.d.ts +0 -7
  194. package/plugins/opencode/delivery.ts +0 -198
  195. /package/dist/{channel-values.d.ts → square-values.d.ts} +0 -0
  196. /package/dist/{channel-values.js → square-values.js} +0 -0
@@ -1,117 +1,48 @@
1
- import { createDefaultWakeTransport } from './wake-transports.js';
2
- export { createDefaultWakeTransport, createWakeTransport } from './wake-transports.js';
3
- import { squareWakeProjection } from './wake-preview.js';
4
- import path from 'node:path';
5
- import { canonicalPath } from './canonical-path.js';
6
- import fs from 'node:fs';
7
- import { indexedSquareEntries } from './square-index.js';
8
- import { InternalSquareError, SquareError } from './model.js';
9
- import { channelArtifact, createArtifactPort } from './artifact.js';
10
- import { deliverPending, sweepPending } from './delivery-operations.js';
11
- import { formatMessageId } from './message.js';
12
- export const PRIVILEGED_HOOK_BUDGET_MS = 3000;
13
- export function wakeGraceMs(env = process.env) {
14
- const value = Number.parseInt(env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? '5000', 10);
15
- if (!Number.isFinite(value) || value <= 0) {
16
- throw new SquareError('invalid_args', 'Invalid SQUARE_NOTIFY_DELIVERY_WAIT_MS: expected a positive integer.');
17
- }
18
- return value;
1
+ import { createDefaultDeliveryTransports } from './wake-transports.js';
2
+ export { createDefaultDeliveryTransports } from './wake-transports.js';
3
+ /** Best-effort dispatch for currently eligible cards, including cards restored by release. */
4
+ export async function dispatchEligibleCards(ledger, owner, options = {}) {
5
+ const session = await ledger.session(owner);
6
+ if (!session || session.state !== 'attached' || options.signal?.aborted)
7
+ return;
8
+ const pending = await ledger.pending(owner, { generation: session.generation });
9
+ for (const card of pending)
10
+ await dispatchPostedCard(ledger, owner, card.key, options.track ?? (() => undefined), options.env, options.signal, options.transports);
19
11
  }
20
- export async function processActNotificationsOnce(squarePath, actIndex, opts = {}) {
21
- const env = opts.env ?? process.env;
22
- const now = opts.now ?? Date.now;
23
- squarePath = await canonicalPath(squarePath);
24
- const artifact = createArtifactPort(squarePath);
25
- try {
26
- await artifact.read(facts => facts.header());
27
- const transport = await createDefaultWakeTransport(env, opts.transports);
12
+ /** Post, then best-effort dispatch without consulting producer storage. */
13
+ export async function postAndDispatch(ledger, input, options = {}) {
14
+ const result = await ledger.post(input);
15
+ if (result.status === 'posted' || result.status === 'duplicate') {
28
16
  try {
29
- const message = (await artifact.read(facts => { const activity = facts.activity(actIndex); return activity?.kind === 'say' ? activity.messageIndex : undefined; })).value;
30
- if (message === undefined)
31
- return;
32
- await deliverPending({ artifact: channelArtifact(artifact), preparePresentation: squareWakeProjection(artifact), transport, location: squarePath, message: formatMessageId(message), timeoutMs: Number(env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? 5000), now: now() });
17
+ await dispatchEligibleCards(ledger, input.owner, options);
33
18
  }
34
- catch { /* Delivery failure does not undo the activity. */ }
35
- }
36
- catch (error) {
37
- if (error instanceof InternalSquareError && error.code === 'not_found')
38
- throw new SquareError('unavailable', `Square is unavailable at ${squarePath}`);
39
- throw error;
40
- }
41
- finally {
42
- await artifact.close();
19
+ catch { /* Posting is already committed. */ }
43
20
  }
21
+ return result;
44
22
  }
45
- /** Privileged hook fallback: sweep indexed squares plus the current cwd's local squares within one boundary budget. */
46
- export async function sweepPrivilegedPending(cwd, env = process.env, suppliedTransports, deadline = Date.now() + PRIVILEGED_HOOK_BUDGET_MS, signal) {
47
- const remainingMs = () => Math.max(0, deadline - Date.now());
48
- if (remainingMs() === 0 || signal?.aborted)
23
+ /** Best-effort dispatch after a card has been posted; never inspect producer storage. */
24
+ export async function dispatchPostedCard(ledger, owner, key, track, env = process.env, signal, suppliedTransports) {
25
+ if (signal?.aborted)
49
26
  return;
50
- const indexed = await indexedSquareEntries({}, env).catch(() => []);
51
- const paths = new Set(indexed.map((binding) => binding.location));
52
- try {
53
- for (const entry of await fs.promises.readdir(path.join(cwd, '.square'))) {
54
- if (entry.endsWith('.square'))
55
- paths.add(path.join(cwd, '.square', entry));
56
- }
57
- }
58
- catch { /* no local square directory */ }
59
- if (remainingMs() === 0 || signal?.aborted)
27
+ const session = await ledger.session(owner);
28
+ if (!session || session.state !== 'attached')
60
29
  return;
61
- const transport = await createDefaultWakeTransport(env, suppliedTransports);
62
- for (const candidate of paths) {
63
- if (remainingMs() === 0 || signal?.aborted)
64
- break;
65
- try {
66
- const squarePath = await canonicalPath(candidate);
67
- const artifact = createArtifactPort(squarePath, undefined, signal);
68
- try {
69
- await artifact.read(facts => facts.header(), signal);
70
- if (remainingMs() === 0 || signal?.aborted)
71
- break;
72
- const limit = Number.parseInt(env.SQUARE_NOTIFY_SWEEP_LIMIT ?? '8', 10);
73
- const graceMs = 0;
74
- const selected = await sweepPending({ artifact: channelArtifact(artifact), location: squarePath, now: Date.now(), graceMs, limit: Number.isFinite(limit) && limit > 0 ? limit : 8 }).catch(() => []);
75
- for (const messageIndex of selected) {
76
- const remaining = remainingMs();
77
- if (remaining === 0 || signal?.aborted)
78
- break;
79
- const configured = Number(env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? 5000);
80
- const timeoutMs = Math.max(1, Math.min(Number.isFinite(configured) && configured > 0 ? configured : 5000, remaining));
81
- await deliverPending({ artifact: channelArtifact(artifact), preparePresentation: squareWakeProjection(artifact), transport, location: squarePath, message: formatMessageId(messageIndex), timeoutMs, now: Date.now() }).catch(() => undefined);
82
- }
83
- }
84
- finally {
85
- await artifact.close();
86
- }
87
- }
88
- catch { /* stale index entries are ignored by the hook */ }
89
- }
90
- }
91
- /** Select old pending attention at a bounded action boundary for an explicit executor. */
92
- export async function sweepPendingNotifications(squarePath, opts = {}) {
93
- const env = opts.env ?? process.env;
94
- if (env.SQUARE_DISABLE_PASEO_WAKE === '1')
95
- return [];
96
- const now = opts.now ?? Date.now();
97
- const limit = opts.limit ?? 8;
98
- const artifact = createArtifactPort(squarePath);
99
- let selected;
100
- try {
101
- await artifact.read(facts => facts.header());
102
- selected = await sweepPending({ artifact: channelArtifact(artifact), location: squarePath, now, graceMs: wakeGraceMs(env), limit });
103
- }
104
- catch (error) {
105
- if (error instanceof InternalSquareError && error.code === 'not_found')
106
- throw new SquareError('unavailable', `Square is unavailable at ${squarePath}`);
107
- throw error;
108
- }
109
- finally {
110
- await artifact.close();
111
- }
112
- if (opts.dispatchCandidate !== undefined) {
113
- for (const messageIndex of selected)
114
- await opts.dispatchCandidate(messageIndex);
30
+ const transports = suppliedTransports ?? await createDefaultDeliveryTransports(env);
31
+ const configured = Number(env.SQUARE_NOTIFY_DELIVERY_WAIT_MS ?? 5000);
32
+ const timeoutMs = Number.isSafeInteger(configured) && configured > 0 ? configured : 5000;
33
+ for (const route of session.routes) {
34
+ // Claude and Codex custody belongs to their native receivers, including for
35
+ // callers that supply transports explicitly to the producer API.
36
+ if (['codex-queue', 'claude-async-hook', 'claude-native'].includes(route.kind))
37
+ continue;
38
+ if (signal?.aborted)
39
+ return;
40
+ const transport = transports.find(candidate => candidate.kind === route.kind);
41
+ if (!transport)
42
+ continue;
43
+ const result = await ledger.dispatchHandoff(owner, key, transport, { generation: session.generation, timeoutMs, signal });
44
+ track(result);
45
+ if (result.outcome === 'accepted' || result.outcome === 'presented' || result.outcome === 'unknown')
46
+ return;
115
47
  }
116
- return selected;
117
48
  }
@@ -0,0 +1,4 @@
1
+ import type { OperationControl, OperationFailureDetail } from './neutral.js';
2
+ export declare function checkControl(control?: OperationControl): void;
3
+ export declare function squareClock(env: NodeJS.ProcessEnv, clock?: () => number): () => number;
4
+ export declare function failure(error: unknown, detail: OperationFailureDetail): Error;
@@ -0,0 +1,20 @@
1
+ import { SquareError } from './neutral.js';
2
+ export function checkControl(control) {
3
+ if (control?.signal?.aborted)
4
+ throw control.signal.reason ?? new Error('Square operation was cancelled');
5
+ }
6
+ export function squareClock(env, clock) {
7
+ if (clock !== undefined)
8
+ return clock;
9
+ if (env.SQUARE_NOW_MS === undefined)
10
+ return Date.now;
11
+ const value = Number.parseInt(env.SQUARE_NOW_MS, 10);
12
+ if (!Number.isFinite(value))
13
+ throw new SquareError('invalid_args', 'Invalid SQUARE_NOW_MS: expected integer milliseconds.');
14
+ return () => value;
15
+ }
16
+ export function failure(error, detail) {
17
+ const normalized = error instanceof Error ? error : new Error(String(error), { cause: error });
18
+ Object.assign(normalized, { operation: detail });
19
+ return normalized;
20
+ }
@@ -11,6 +11,8 @@ export interface ClaudeNativeTarget {
11
11
  export interface ClaudeNativeControl {
12
12
  readonly deadline: number;
13
13
  readonly signal?: AbortSignal;
14
+ /** Final asynchronous eligibility check after connecting, before writing bytes. */
15
+ readonly beforeSend?: () => Promise<boolean>;
14
16
  }
15
17
  export type NativeWriteResult = {
16
18
  readonly outcome: 'written';
@@ -43,6 +43,11 @@ export async function writeClaudeNative(target, text, control) {
43
43
  throw new LocalEndpointError('timeout');
44
44
  socket = await connectLocal(endpoint, { signal: scope.signal });
45
45
  scope.check();
46
+ if (Date.now() >= control.deadline)
47
+ throw new LocalEndpointError('timeout');
48
+ if (control.beforeSend && !await scope.wait(control.beforeSend()))
49
+ return { outcome: 'unavailable', code: 'aborted', message: 'Native inbox write suppressed.' };
50
+ scope.check();
46
51
  if (Date.now() >= control.deadline)
47
52
  throw new LocalEndpointError('timeout');
48
53
  // A receiver that never drains must not hold the caller past its deadline.
@@ -1,13 +1,18 @@
1
+ export interface CodexStopEpoch {
2
+ readonly token: string;
3
+ readonly turnId: string;
4
+ }
1
5
  export interface CodexBoundary {
2
- readonly lastStop: number;
3
- readonly lastNonStop: number;
6
+ readonly stop?: CodexStopEpoch;
4
7
  readonly ended?: true;
5
8
  }
6
9
  export interface CodexBoundaryStore {
7
10
  read(sessionId: string): Promise<CodexBoundary | undefined>;
8
- queueEligible(sessionId: string): Promise<boolean>;
9
- record(sessionId: string, event: 'Stop' | 'non-stop' | 'start' | 'end', signal?: AbortSignal): Promise<void>;
10
- clear(sessionId: string, signal?: AbortSignal): Promise<void>;
11
+ queueEligible(sessionId: string, epoch?: CodexStopEpoch): Promise<boolean>;
12
+ record(sessionId: string, event: 'Stop' | 'non-stop' | 'start' | 'end', options?: {
13
+ readonly signal?: AbortSignal;
14
+ readonly turnId?: string;
15
+ }): Promise<void>;
11
16
  }
12
- /** Caller-owned observation file, shared by independent hook and sender processes. */
17
+ /** Receiver-owned idle authority. Old or unverifiable records never arm a send. */
13
18
  export declare function createCodexBoundaryStore(file: string): CodexBoundaryStore;
@@ -1,8 +1,9 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { withFileLock } from './file-lock.js';
4
- const empty = () => ({ v: 1, nextSequence: 0, threads: Object.create(null) });
5
- /** Caller-owned observation file, shared by independent hook and sender processes. */
4
+ const empty = () => ({ v: 2, threads: Object.create(null) });
5
+ const identity = (value) => typeof value === 'string' && value.trim().length > 0 && !value.includes('\0');
6
+ /** Receiver-owned idle authority. Old or unverifiable records never arm a send. */
6
7
  export function createCodexBoundaryStore(file) {
7
8
  const load = async () => {
8
9
  let raw;
@@ -16,16 +17,14 @@ export function createCodexBoundaryStore(file) {
16
17
  }
17
18
  try {
18
19
  const value = JSON.parse(raw);
19
- if (value?.v !== 1 || !Number.isSafeInteger(value.nextSequence) || value.nextSequence < 0 ||
20
- !value.threads || typeof value.threads !== 'object')
20
+ if (value?.v !== 2 || !value.threads || typeof value.threads !== 'object' || Array.isArray(value.threads))
21
21
  return empty();
22
22
  const result = empty();
23
- result.nextSequence = value.nextSequence;
24
- for (const [id, boundary] of Object.entries(value.threads)) {
25
- const item = boundary;
26
- if (item && Number.isSafeInteger(item.lastStop) && item.lastStop >= 0 &&
27
- Number.isSafeInteger(item.lastNonStop) && item.lastNonStop >= 0)
28
- result.threads[id] = item;
23
+ for (const [id, item] of Object.entries(value.threads)) {
24
+ if (!item || typeof item !== 'object')
25
+ continue;
26
+ result.threads[id] = item.ended === true ? { ended: true }
27
+ : identity(item.stop?.token) && identity(item.stop?.turnId) ? { stop: { token: item.stop.token, turnId: item.stop.turnId } } : {};
29
28
  }
30
29
  return result;
31
30
  }
@@ -33,43 +32,35 @@ export function createCodexBoundaryStore(file) {
33
32
  return empty();
34
33
  }
35
34
  };
36
- const update = (change, signal) => withFileLock(`${file}.lock`, { retryMs: 10, signal }, async () => {
37
- const state = await load();
38
- change(state);
39
- signal?.throwIfAborted();
40
- const temporary = `${file}.${randomUUID()}.tmp`;
41
- try {
42
- await fs.writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
43
- await fs.rename(temporary, file);
44
- }
45
- finally {
46
- await fs.unlink(temporary).catch(() => { });
47
- }
48
- });
49
35
  const read = async (id) => id ? (await load()).threads[id] : undefined;
50
36
  return {
51
37
  read,
52
- async queueEligible(id) {
38
+ async queueEligible(id, epoch) {
53
39
  const boundary = await read(id);
54
- return boundary !== undefined && boundary.ended !== true && boundary.lastStop > boundary.lastNonStop;
40
+ return boundary?.ended !== true && boundary?.stop !== undefined &&
41
+ (epoch === undefined || boundary.stop.token === epoch.token && boundary.stop.turnId === epoch.turnId);
55
42
  },
56
- async record(id, event, signal) {
57
- if (!id)
43
+ async record(id, event, options = {}) {
44
+ if (!identity(id))
58
45
  return;
59
- await update((state) => {
60
- const previous = state.threads[id] ?? { lastStop: 0, lastNonStop: 0 };
61
- // A late Stop/tool callback must not reopen an ended session.
62
- if (previous.ended && event !== 'start')
46
+ await withFileLock(`${file}.lock`, { retryMs: 10, signal: options.signal }, async () => {
47
+ const state = await load();
48
+ // A late callback cannot reopen an ended native session.
49
+ if (state.threads[id]?.ended && event !== 'start')
63
50
  return;
64
- state.nextSequence += 1;
65
- state.threads[id] = event === 'start'
66
- ? { lastStop: 0, lastNonStop: state.nextSequence }
67
- : event === 'end' ? { ...previous, lastNonStop: state.nextSequence, ended: true }
68
- : event === 'Stop' ? { ...previous, lastStop: state.nextSequence }
69
- : { ...previous, lastNonStop: state.nextSequence };
70
- }, signal);
51
+ state.threads[id] = event === 'end' ? { ended: true }
52
+ : event === 'Stop' && identity(options.turnId) ? { stop: { token: randomUUID(), turnId: options.turnId } } : {};
53
+ options.signal?.throwIfAborted();
54
+ const temporary = `${file}.${randomUUID()}.tmp`;
55
+ try {
56
+ await fs.writeFile(temporary, JSON.stringify(state), { mode: 0o600 });
57
+ options.signal?.throwIfAborted();
58
+ await fs.rename(temporary, file);
59
+ }
60
+ finally {
61
+ await fs.unlink(temporary).catch(() => { });
62
+ }
63
+ });
71
64
  },
72
- async clear(id, signal) { if (id)
73
- await update((state) => { delete state.threads[id]; }, signal); },
74
65
  };
75
66
  }
@@ -1,26 +1,31 @@
1
1
  import type { CodexBoundaryStore } from './codex-boundary.js';
2
2
  export { createCodexBoundaryStore } from './codex-boundary.js';
3
- export type { CodexBoundary, CodexBoundaryStore } from './codex-boundary.js';
3
+ export type { CodexBoundary, CodexBoundaryStore, CodexStopEpoch } from './codex-boundary.js';
4
+ export { codexIdleAtStop } from './codex.js';
5
+ export type CodexActiveEvent = 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse';
6
+ export type CodexHookEvent = CodexActiveEvent | 'SessionStart' | 'SessionEnd' | 'Stop' | 'Interrupt';
7
+ export interface CodexHookInput {
8
+ readonly session_id: string;
9
+ readonly hook_event_name: CodexHookEvent;
10
+ readonly turn_id?: string;
11
+ }
4
12
  export interface CodexHookResponse {
5
13
  readonly hookSpecificOutput: {
6
- readonly hookEventName: 'PostToolUse';
14
+ readonly hookEventName: CodexActiveEvent;
7
15
  readonly additionalContext: string;
8
16
  };
9
17
  }
10
18
  export interface CodexHookOptions {
11
19
  readonly boundaries: CodexBoundaryStore;
12
- /** The host owns pending text, claims and cancellation. Honor signal before presenting;
13
- * if cancelled before an effect, release custody and resolve undefined or reject.
14
- * After presenting, finish any cancellation-independent settlement and return its text,
15
- * even if signal aborts meanwhile. This callback is awaited, never raced or abandoned. */
16
- readonly pull?: (sessionId: string, signal: AbortSignal) => Promise<string | undefined>;
17
- /** Dispatch the host's pending work; Stop merely makes queue eligible. */
18
- readonly sweep?: (cwd: string | undefined, deadline: number, signal: AbortSignal) => Promise<void>;
20
+ /** The host owns claims and cancellation. After an external effect, finish its
21
+ * cancellation-independent settlement and retain the output. Never abandon it. */
22
+ readonly pull?: (sessionId: string, signal: AbortSignal, event: CodexActiveEvent) => Promise<string | undefined>;
19
23
  readonly end?: (sessionId: string, signal: AbortSignal) => Promise<void>;
24
+ readonly interrupt?: (sessionId: string, signal: AbortSignal) => Promise<void>;
20
25
  readonly trace?: (stage: string, detail?: string) => void;
21
26
  readonly timeoutMs?: number;
22
- readonly sweepTimeoutMs?: number;
23
27
  readonly signal?: AbortSignal;
24
28
  }
25
- /** Stateless hook protocol. No message source, session identity or service is invented here. */
26
- export declare function handleCodexHook(input: unknown, options: CodexHookOptions): Promise<CodexHookResponse | undefined>;
29
+ export declare function parseCodexHook(input: unknown): CodexHookInput | undefined;
30
+ /** Native synchronous boundaries; Stop only records authority for its async receiver. */
31
+ export declare function handleCodexHook(value: unknown, options: CodexHookOptions): Promise<CodexHookResponse | undefined>;
@@ -1,57 +1,44 @@
1
1
  import { deadline, validSignal, validTimeout } from './deadline.js';
2
2
  import { isRecord } from './guards.js';
3
3
  export { createCodexBoundaryStore } from './codex-boundary.js';
4
- /** Stateless hook protocol. No message source, session identity or service is invented here. */
5
- export async function handleCodexHook(input, options) {
6
- if (!isRecord(input) || typeof input.session_id !== 'string' || !input.session_id.trim())
4
+ export { codexIdleAtStop } from './codex.js';
5
+ export function parseCodexHook(input) {
6
+ const identity = (value) => typeof value === 'string' && value.trim().length > 0 && !value.includes('\0');
7
+ if (!isRecord(input) || !identity(input.session_id) ||
8
+ !['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'SessionEnd', 'Stop', 'Interrupt'].includes(input.hook_event_name) ||
9
+ (['Stop', 'Interrupt'].includes(input.hook_event_name) && !identity(input.turn_id)))
7
10
  return;
8
- const event = input.hook_event_name;
9
- if (!['SessionStart', 'SessionResume', 'UserPromptSubmit', 'SessionEnd', 'PostToolUse', 'Stop'].includes(event))
11
+ return input;
12
+ }
13
+ /** Native synchronous boundaries; Stop only records authority for its async receiver. */
14
+ export async function handleCodexHook(value, options) {
15
+ const input = parseCodexHook(value);
16
+ if (!input)
10
17
  return;
11
- if (!validTimeout(options.timeoutMs) || !validTimeout(options.sweepTimeoutMs) || !validSignal(options.signal)) {
18
+ if (!validTimeout(options.timeoutMs) || !validSignal(options.signal))
12
19
  throw new TypeError('Invalid hook control.');
13
- }
14
20
  const scope = deadline(options.timeoutMs ?? 4500, options.signal);
15
- const sweepDeadline = Date.now() + Math.min(options.sweepTimeoutMs ?? 3000, options.timeoutMs ?? 4500);
16
- const id = input.session_id;
21
+ const event = input.hook_event_name, id = input.session_id;
17
22
  try {
18
23
  scope.check();
19
- if (event === 'SessionEnd') {
20
- await scope.wait(options.boundaries.record(id, 'end', scope.signal));
21
- await scope.wait(options.end?.(id, scope.signal) ?? Promise.resolve()).catch(() => { });
22
- return;
23
- }
24
- await scope.wait(options.boundaries.record(id, event === 'SessionStart' || event === 'SessionResume' ? 'start' : event === 'Stop' ? 'Stop' : 'non-stop', scope.signal));
24
+ await scope.wait(options.boundaries.record(id, event === 'SessionStart' ? 'start' : event === 'SessionEnd' ? 'end' : event === 'Stop' ? 'Stop' : 'non-stop', { signal: scope.signal, turnId: input.turn_id }));
25
25
  options.trace?.('boundary-record');
26
- if (event !== 'Stop' && event !== 'PostToolUse')
26
+ if (event === 'SessionEnd' || event === 'Interrupt') {
27
+ await (event === 'SessionEnd' ? options.end : options.interrupt)?.(id, scope.signal);
27
28
  return;
28
- let context;
29
- if (event === 'PostToolUse') {
30
- try {
31
- // A host's successful presentation settlement may outlive this signal. Retain
32
- // its output instead of leaving background work to consume it without a response.
33
- context = await options.pull?.(id, scope.signal);
34
- options.trace?.('presentation');
35
- }
36
- catch (error) {
37
- options.trace?.('presentation', `error=${error instanceof Error ? error.name : 'Error'}`);
38
- }
39
29
  }
40
- if (!scope.signal.aborted && Date.now() < sweepDeadline) {
41
- await scope.wait(options.sweep?.(typeof input.cwd === 'string' ? input.cwd : undefined, sweepDeadline, scope.signal) ?? Promise.resolve()).catch(() => { });
30
+ if (event !== 'UserPromptSubmit' && event !== 'PreToolUse' && event !== 'PostToolUse')
31
+ return;
32
+ try {
33
+ const context = await options.pull?.(id, scope.signal, event);
34
+ options.trace?.('presentation');
35
+ if (context !== undefined)
36
+ return { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
42
37
  }
43
- options.trace?.('sweep', scope.signal.aborted ? 'aborted=true' : '');
44
- // A successful pull may already have committed host presentation evidence.
45
- // A later, unrelated sweep timeout must not discard its completed output.
46
- if (context !== undefined) {
47
- return { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context } };
38
+ catch (error) {
39
+ options.trace?.('presentation', `error=${error instanceof Error ? error.name : 'Error'}`);
48
40
  }
49
41
  }
50
- catch (error) {
51
- if (scope.signal.aborted)
52
- throw scope.signal.reason;
53
- throw error;
54
- }
55
42
  finally {
56
43
  scope.close();
57
44
  }
@@ -1,4 +1,7 @@
1
1
  import type { CodexAppServerConnectOptions, CodexDeliveryResult, CodexSendTextOptions, CodexTarget } from './types.js';
2
+ /** Newest-last live history is verified for Codex 0.166.0 only. A sampled read
3
+ * cannot make a later external queue atomic with native turn starts. */
4
+ export declare function codexIdleAtStop(options: CodexAppServerConnectOptions, turnId: string): Promise<boolean>;
2
5
  /** Address an existing thread on an already running app-server. Never starts a
3
6
  * service, resumes a thread, or claims ownership of a standalone CLI session. */
4
7
  export declare function connectCodex(options: CodexAppServerConnectOptions): Promise<CodexTarget>;
@@ -143,6 +143,7 @@ class Peer {
143
143
  throw new Fault('invalid_response');
144
144
  scope.check();
145
145
  this.socket.send(JSON.stringify({ method: 'initialized' }));
146
+ return result.userAgent;
146
147
  }
147
148
  call(method, params, scope, dispatch) {
148
149
  scope.check();
@@ -239,9 +240,7 @@ async function discoverTurn(peer, metadata, sessionId, scope) {
239
240
  cursors.add(cursor);
240
241
  }
241
242
  }
242
- /** Address an existing thread on an already running app-server. Never starts a
243
- * service, resumes a thread, or claims ownership of a standalone CLI session. */
244
- export async function connectCodex(options) {
243
+ function validatedConnection(options) {
245
244
  if (!isRecord(options) || options.harness !== 'codex' ||
246
245
  (options.transport !== undefined && options.transport !== 'app-server') || !isIdentity(options.sessionId) ||
247
246
  !validTimeout(options.timeoutMs) || !validSignal(options.signal) ||
@@ -250,7 +249,45 @@ export async function connectCodex(options) {
250
249
  (options.token !== undefined && (!isIdentity(options.token) || /[\r\n]/.test(options.token)))) {
251
250
  throw new ConnectionError('invalid_arguments');
252
251
  }
253
- const connection = resolveConnection(options);
252
+ return resolveConnection(options);
253
+ }
254
+ /** Newest-last live history is verified for Codex 0.166.0 only. A sampled read
255
+ * cannot make a later external queue atomic with native turn starts. */
256
+ export async function codexIdleAtStop(options, turnId) {
257
+ const connection = validatedConnection(options);
258
+ if (!isIdentity(turnId))
259
+ return false;
260
+ const scope = deadline(options.timeoutMs, options.signal);
261
+ let peer;
262
+ try {
263
+ peer = await open(connection, scope);
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
+ if (!/^[A-Za-z0-9._-]+\/0\.166\.0(?:\s|$)/.test(version))
268
+ return false;
269
+ 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;
276
+ const newest = thread.turns.at(-1);
277
+ return newest.id === turnId && newest.status === 'completed';
278
+ }
279
+ catch {
280
+ return false;
281
+ }
282
+ finally {
283
+ scope.close();
284
+ await peer?.close();
285
+ }
286
+ }
287
+ /** Address an existing thread on an already running app-server. Never starts a
288
+ * service, resumes a thread, or claims ownership of a standalone CLI session. */
289
+ export async function connectCodex(options) {
290
+ const connection = validatedConnection(options);
254
291
  const scope = deadline(options.timeoutMs, options.signal);
255
292
  let peer;
256
293
  try {
@@ -30,7 +30,9 @@ export type PiAgent = Steerable & Queueable & {
30
30
  readonly harness: 'pi';
31
31
  readonly sessionId: string;
32
32
  };
33
- export type PaseoAgent = Steerable<KeyedSendOptions> & {
33
+ export type PaseoAgent = Steerable<KeyedSendOptions & {
34
+ readonly beforeSend?: () => Promise<boolean>;
35
+ }> & {
34
36
  readonly harness: 'paseo';
35
37
  readonly agentId: string;
36
38
  };
@@ -110,7 +110,7 @@ export async function connect(options) {
110
110
  throw new DeliveryError('invalid_arguments');
111
111
  try {
112
112
  const result = await opened.send(text, spec.keyed
113
- ? { delivery, inputId: id, timeoutMs: options.timeoutMs, signal: options.signal }
113
+ ? { delivery, inputId: id, timeoutMs: options.timeoutMs, signal: options.signal, ...(spec.harness === 'paseo' ? { beforeSend: options.beforeSend } : {}) }
114
114
  : { delivery, timeoutMs: options.timeoutMs, signal: options.signal });
115
115
  if (result.state !== spec.accepted)
116
116
  throw failure(result, 'send', id);
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { deadline, validSignal, validTimeout } from './deadline.js';
2
+ import { deadline, DEFAULT_TIMEOUT_MS, validSignal, validTimeout } from './deadline.js';
3
3
  import { ConnectionError } from './errors.js';
4
4
  import { isIdentity, isRecord } from './guards.js';
5
5
  import { daemonCredential, openPaseoClient, paseoHome, resolvePaseoDaemon, } from './paseo-native.js';
@@ -59,26 +59,35 @@ export async function sendPaseoText(target, text, options = {}) {
59
59
  if (connection === undefined || typeof text !== 'string' || text.length === 0 ||
60
60
  !validTimeout(options.timeoutMs) || !validSignal(options.signal) ||
61
61
  (options.inputId !== undefined && !isIdentity(options.inputId)) ||
62
+ (options.beforeSend !== undefined && typeof options.beforeSend !== 'function') ||
62
63
  (options.delivery !== undefined && options.delivery !== 'steer')) {
63
64
  throw new TypeError('Invalid sendText arguments.');
64
65
  }
65
66
  const inputId = options.inputId ?? randomUUID();
66
67
  const attempt = { harness: 'paseo', agentId: target.agentId, inputId };
67
68
  const scope = deadline(options.timeoutMs, options.signal);
69
+ const until = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
68
70
  const client = await openPaseoClient(connection.target, connection.credential, options.timeoutMs);
69
71
  // The connection is the uncertainty boundary: before it, nothing was written.
70
72
  let connected = false;
73
+ let sent = false;
71
74
  try {
72
75
  scope.check();
73
76
  await scope.wait(client.connect());
74
77
  connected = true;
75
78
  scope.check();
79
+ if (options.beforeSend && !await scope.wait(options.beforeSend()))
80
+ return { ...attempt, state: 'unavailable', code: 'aborted' };
81
+ scope.check();
82
+ if (Date.now() >= until)
83
+ return { ...attempt, state: 'unavailable', code: 'timeout' };
84
+ sent = true;
76
85
  await scope.wait(client.sendAgentMessage(target.agentId, text, { messageId: inputId, activeTurnBehavior: 'steer' }));
77
86
  return { ...attempt, state: 'admitted' };
78
87
  }
79
88
  catch (error) {
80
89
  if (scope.code !== undefined) {
81
- return { ...attempt, state: connected ? 'unknown' : 'unavailable', code: scope.code };
90
+ return { ...attempt, state: sent ? 'unknown' : 'unavailable', code: scope.code };
82
91
  }
83
92
  if (error instanceof Error && error.name === 'DaemonAuthenticationError') {
84
93
  return { ...attempt, state: 'unavailable', code: 'authentication_failed' };
@@ -50,6 +50,7 @@ export interface PaseoSendTextOptions {
50
50
  readonly inputId?: string;
51
51
  readonly timeoutMs?: number;
52
52
  readonly signal?: AbortSignal;
53
+ readonly beforeSend?: () => Promise<boolean>;
53
54
  }
54
55
  export interface CodexSendTextOptions {
55
56
  readonly delivery?: Delivery;