@astrosheep/square 0.3.91 → 0.3.93

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 (44) hide show
  1. package/dist/cli/context.js +2 -1
  2. package/dist/codex-address.d.ts +3 -0
  3. package/dist/codex-address.js +8 -0
  4. package/dist/codex-boundary-state.d.ts +6 -8
  5. package/dist/codex-boundary-state.js +8 -85
  6. package/dist/codex-hook.d.ts +1 -3
  7. package/dist/codex-hook.js +20 -76
  8. package/dist/codex-queue.d.ts +5 -17
  9. package/dist/codex-queue.js +21 -64
  10. package/dist/file-lock.d.ts +1 -17
  11. package/dist/file-lock.js +1 -64
  12. package/dist/harness-links.d.ts +1 -10
  13. package/dist/harness-links.js +144 -109
  14. package/dist/harness.js +4 -13
  15. package/dist/mcp.js +2 -1
  16. package/dist/packages/agent-delivery/src/codex-boundary.d.ts +13 -0
  17. package/dist/packages/agent-delivery/src/codex-boundary.js +75 -0
  18. package/dist/packages/agent-delivery/src/codex-cli.d.ts +10 -0
  19. package/dist/packages/agent-delivery/src/codex-cli.js +69 -0
  20. package/dist/packages/agent-delivery/src/codex-endpoint.d.ts +3 -0
  21. package/dist/packages/agent-delivery/src/codex-endpoint.js +14 -0
  22. package/dist/packages/agent-delivery/src/codex-hook.d.ts +26 -0
  23. package/dist/packages/agent-delivery/src/codex-hook.js +58 -0
  24. package/dist/packages/agent-delivery/src/codex-queue.d.ts +24 -0
  25. package/dist/packages/agent-delivery/src/codex-queue.js +58 -0
  26. package/dist/packages/agent-delivery/src/codex.d.ts +7 -0
  27. package/dist/packages/agent-delivery/src/codex.js +332 -0
  28. package/dist/packages/agent-delivery/src/errors.d.ts +1 -1
  29. package/dist/packages/agent-delivery/src/errors.js +1 -0
  30. package/dist/packages/agent-delivery/src/file-lock.d.ts +17 -0
  31. package/dist/packages/agent-delivery/src/file-lock.js +64 -0
  32. package/dist/packages/agent-delivery/src/index.d.ts +17 -3
  33. package/dist/packages/agent-delivery/src/index.js +29 -4
  34. package/dist/packages/agent-delivery/src/types.d.ts +58 -3
  35. package/dist/square-location.js +2 -1
  36. package/guides/codex-delivery.md +79 -0
  37. package/package.json +2 -1
  38. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  39. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  40. package/plugins/codex/hooks/hooks.json +3 -0
  41. package/plugins/paseo/client/markdown.tsx +16 -38
  42. package/plugins/paseo/client/palette.ts +62 -0
  43. package/plugins/paseo/client/square-message.tsx +75 -45
  44. package/plugins/paseo/shared/message.ts +16 -1
@@ -1,79 +1,10 @@
1
1
  import fs from 'node:fs';
2
- import os from 'node:os';
3
2
  import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
3
+ import { isDeepStrictEqual } from 'node:util';
5
4
  import crossSpawn from 'cross-spawn';
5
+ import { applyEdits, createScanner, findNodeAtLocation, modify, parse, parseTree } from 'jsonc-parser';
6
6
  import { SQUARE_IDENTITY } from './identity.js';
7
7
  import { truncateExternalDiagnostic } from './presentation.js';
8
- function packageRoot() {
9
- // Emitted modules live in dist; package assets are one level above them.
10
- return fileURLToPath(new URL('../', import.meta.url));
11
- }
12
- export function installHarnessLinks(links, force = false) {
13
- const prepared = links.map((link) => {
14
- if (!fs.existsSync(link.source)) {
15
- throw new Error(`Harness link source is missing: ${link.source}`);
16
- }
17
- const existing = (() => {
18
- try {
19
- return fs.lstatSync(link.target);
20
- }
21
- catch (error) {
22
- if (error.code === 'ENOENT')
23
- return undefined;
24
- throw error;
25
- }
26
- })();
27
- return {
28
- ...link,
29
- existing,
30
- sourceIsDirectory: fs.statSync(link.source).isDirectory(),
31
- alreadyManaged: existing !== undefined && sameLink(link.source, link.target),
32
- };
33
- });
34
- for (const { target, existing, alreadyManaged } of prepared) {
35
- if (existing !== undefined && !alreadyManaged && !force)
36
- throw new Error(`Refusing to overwrite existing link: ${target}\nPass -f to replace it.`);
37
- }
38
- for (const { source, target, existing, sourceIsDirectory, alreadyManaged } of prepared) {
39
- if (alreadyManaged)
40
- continue;
41
- fs.mkdirSync(path.dirname(target), { recursive: true });
42
- if (existing !== undefined)
43
- fs.rmSync(target, { recursive: true, force: true });
44
- const symlinkType = os.platform() === 'win32' && sourceIsDirectory ? 'junction' : 'file';
45
- fs.symlinkSync(source, target, symlinkType);
46
- }
47
- return prepared.map(({ target }) => target);
48
- }
49
- function sameLink(source, target) {
50
- try {
51
- return fs.realpathSync(source) === fs.realpathSync(target);
52
- }
53
- catch {
54
- return false;
55
- }
56
- }
57
- export function uninstallHarnessLinks(links) {
58
- const removed = [];
59
- for (const link of links) {
60
- try {
61
- if (fs.lstatSync(link.target).isSymbolicLink() && sameLink(link.source, link.target)) {
62
- fs.rmSync(link.target, { force: true });
63
- removed.push(link.target);
64
- }
65
- }
66
- catch {
67
- // A missing or user-owned target is preserved.
68
- }
69
- }
70
- return removed;
71
- }
72
- export function doctorHarnessLinks(links) {
73
- return links.map((link) => sameLink(link.source, link.target)
74
- ? `✓ Square ${link.kind ?? 'link'} ${link.target}`
75
- : `○ Square ${link.kind ?? 'link'} missing ${link.target}`);
76
- }
77
8
  function runOpenCode(homeDir, args) {
78
9
  const prefix = (() => {
79
10
  try {
@@ -94,39 +25,150 @@ function runOpenCode(homeDir, args) {
94
25
  return { status: result.status ?? 1, stdout: result.stdout || '', stderr: result.stderr || '' };
95
26
  }
96
27
  export function installOpenCodePlugin(homeDir, force = false, run = runOpenCode) {
97
- // OpenCode v2's `plugin add` is idempotent and has no --global/--force flags.
28
+ // Explicit `square install` replaces owned pins/duplicates with one unpinned
29
+ // current-field entry; updating an exact old pin otherwise succeeds unchanged.
98
30
  void force;
99
- const args = ['plugin', 'add', SQUARE_IDENTITY.packageName];
31
+ const configs = readOpenCodeConfigs(homeDir);
32
+ const entries = configs.flatMap((config) => config.entries);
33
+ const options = entries.map((entry) => entry.options).filter((value) => value !== undefined);
34
+ if (options.some((value) => !isDeepStrictEqual(value, options[0]))) {
35
+ throw new Error('Conflicting Square plugin options in OpenCode configuration; reconcile the owned plugin/plugins entries before installing.');
36
+ }
37
+ const canonical = options.length ? { package: SQUARE_IDENTITY.packageName, options: options[0] } : SQUARE_IDENTITY.packageName;
38
+ const targets = configs.length ? configs : [{ target: openCodeConfigPaths(homeDir)[0], source: '{}', entries: [] }];
39
+ const alreadyCanonical = entries.length === 1 && configs[0]?.entries[0]?.field === 'plugins'
40
+ && isDeepStrictEqual(entries[0].value, canonical);
41
+ const changes = alreadyCanonical ? [] : targets.map(({ target, source, entries }, index) => {
42
+ let next = source;
43
+ for (const { field, index } of entries.toReversed())
44
+ next = removeOpenCodeEntry(next, field, index);
45
+ if (index === 0)
46
+ next = applyEdits(next, modify(next, ['plugins', -1], canonical, {}));
47
+ return { target, source, next };
48
+ }).filter(({ source, next }) => source !== next);
49
+ // Appending just our element avoids native add's whole-array rewrite, which
50
+ // drops comments belonging to unrelated plugins. Native add still owns npm.
51
+ for (const { target, next } of changes) {
52
+ fs.mkdirSync(path.dirname(target), { recursive: true });
53
+ fs.writeFileSync(target, next);
54
+ }
55
+ if (!entries.some(({ spec }) => spec === SQUARE_IDENTITY.packageName)) {
56
+ try {
57
+ openCodeCommand(homeDir, ['plugin', 'add', SQUARE_IDENTITY.packageName], run, 'install');
58
+ }
59
+ catch (error) {
60
+ // Undo only our preparation, never files the native command/user changed.
61
+ for (const { target, source, next } of changes) {
62
+ if (fs.existsSync(target) && fs.readFileSync(target, 'utf8') === next) {
63
+ if (configs.length)
64
+ fs.writeFileSync(target, source);
65
+ else
66
+ fs.rmSync(target);
67
+ }
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ // Even add can reuse an older cached package. Always request the latest.
73
+ openCodeCommand(homeDir, ['plugin', 'update', SQUARE_IDENTITY.packageName], run, 'update');
74
+ return [SQUARE_IDENTITY.packageName];
75
+ }
76
+ function openCodeCommand(homeDir, args, run, operation) {
100
77
  const result = run(homeDir, args);
101
78
  if (result.status !== 0)
102
- throw new Error(`OpenCode plugin install failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
103
- return [SQUARE_IDENTITY.packageName];
79
+ throw new Error(`OpenCode ${operation} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`);
80
+ return result.stdout;
104
81
  }
105
- function configPath(homeDir) {
82
+ function openCodeConfigPaths(homeDir) {
106
83
  const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homeDir, '.config');
107
- return path.join(configHome, 'opencode', 'opencode.jsonc');
84
+ const root = path.resolve(configHome, 'opencode');
85
+ return [root, path.join(root, '.opencode')].flatMap((directory) => ['opencode.json', 'opencode.jsonc'].map((name) => path.join(directory, name)));
86
+ }
87
+ function readOpenCodeConfigs(homeDir) {
88
+ return openCodeConfigPaths(homeDir).flatMap((target) => {
89
+ let source;
90
+ try {
91
+ source = fs.readFileSync(target, 'utf8');
92
+ }
93
+ catch (error) {
94
+ if (error.code === 'ENOENT')
95
+ return [];
96
+ throw error;
97
+ }
98
+ const errors = [];
99
+ const config = parse(source, errors, { allowTrailingComma: true });
100
+ if (errors.length || typeof config !== 'object' || config === null || Array.isArray(config)) {
101
+ throw new Error(`Invalid OpenCode configuration: ${target}`);
102
+ }
103
+ const entries = ['plugin', 'plugins'].flatMap((field) => {
104
+ const plugins = config[field];
105
+ if (plugins === undefined)
106
+ return [];
107
+ if (!Array.isArray(plugins))
108
+ throw new Error(`Invalid OpenCode ${field} configuration: ${target}`);
109
+ return plugins.flatMap((entry, index) => {
110
+ const spec = openCodePluginSpec(entry);
111
+ const options = Array.isArray(entry) ? entry[1]
112
+ : typeof entry === 'object' && entry !== null ? entry.options : undefined;
113
+ return spec === undefined ? [] : [{ field, index, spec, options, value: entry }];
114
+ });
115
+ });
116
+ return [{ target, source, entries }];
117
+ });
108
118
  }
109
- function removeConfiguredPlugin(homeDir) {
110
- const target = configPath(homeDir);
111
- let source;
119
+ export function uninstallOpenCodePlugin(homeDir) {
120
+ // Native remove only handles `plugins` in one file. Delete our elements in
121
+ // both fields/files, from the end so indexes remain stable, keeping JSONC.
122
+ const changes = readOpenCodeConfigs(homeDir).filter(({ entries }) => entries.length).map(({ target, source, entries }) => {
123
+ for (const { field, index } of entries.reverse()) {
124
+ source = removeOpenCodeEntry(source, field, index);
125
+ }
126
+ return { target, source };
127
+ });
128
+ for (const { target, source } of changes)
129
+ fs.writeFileSync(target, source);
130
+ return changes.length ? [SQUARE_IDENTITY.packageName] : [];
131
+ }
132
+ // jsonc-parser's array `modify` also removes intervening comments. Use its AST
133
+ // and scanner to remove only the owned value and one separator, not the trivia.
134
+ function removeOpenCodeEntry(source, field, index) {
135
+ const array = findNodeAtLocation(parseTree(source), [field]);
136
+ const node = array.children[index];
137
+ const scanner = createScanner(source, true);
138
+ scanner.setPosition(node.offset + node.length);
139
+ let comma = scanner.scan() === 5 /* SyntaxKind.CommaToken */ ? scanner.getTokenOffset() : undefined;
140
+ if (comma === undefined && index > 0) {
141
+ const previous = array.children[index - 1];
142
+ scanner.setPosition(previous.offset + previous.length);
143
+ if (scanner.scan() === 5 /* SyntaxKind.CommaToken */)
144
+ comma = scanner.getTokenOffset();
145
+ }
146
+ return applyEdits(source, [
147
+ { offset: node.offset, length: node.length, content: '' },
148
+ ...(comma === undefined ? [] : [{ offset: comma, length: 1, content: '' }]),
149
+ ]);
150
+ }
151
+ function openCodePluginSpec(entry) {
152
+ const spec = typeof entry === 'string' ? entry : Array.isArray(entry) ? entry[0]
153
+ : typeof entry === 'object' && entry !== null && 'package' in entry ? entry.package : undefined;
154
+ return typeof spec === 'string' && (spec === SQUARE_IDENTITY.packageName || spec.startsWith(`${SQUARE_IDENTITY.packageName}@`))
155
+ ? spec : undefined;
156
+ }
157
+ function parseOpenCodeConfig(stdout) {
158
+ let sources;
112
159
  try {
113
- source = fs.readFileSync(target, 'utf8');
160
+ sources = JSON.parse(stdout);
114
161
  }
115
162
  catch {
116
- return false;
163
+ throw new Error('OpenCode debug config returned invalid JSON');
117
164
  }
118
- const escaped = SQUARE_IDENTITY.packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
119
- const packageLine = new RegExp(`^\\s*"${escaped}(?:@[^"\\n]+)?"\\s*,?\\s*$`, 'm');
120
- const next = source.replace(packageLine, '');
121
- if (next === source)
122
- return false;
123
- fs.writeFileSync(target, next);
124
- return true;
125
- }
126
- export function uninstallOpenCodePlugin(homeDir) {
127
- return removeConfiguredPlugin(homeDir) ? [SQUARE_IDENTITY.packageName] : [];
165
+ if (!Array.isArray(sources) || sources.some((source) => source === null || typeof source !== 'object'
166
+ || !['document', 'directory'].includes(source.type))) {
167
+ throw new Error('OpenCode debug config returned unexpected configuration sources');
168
+ }
169
+ return sources;
128
170
  }
129
- /** Verify that OpenCode accepts its resolved runtime configuration after links are installed. */
171
+ /** Config sources prove configuration, not that a running session loaded the plugin. */
130
172
  export function verifyOpenCodeRuntime(homeDir, run = runOpenCode) {
131
173
  try {
132
174
  const result = run(homeDir, ['debug', 'config']);
@@ -134,28 +176,21 @@ export function verifyOpenCodeRuntime(homeDir, run = runOpenCode) {
134
176
  const diagnostic = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
135
177
  return `✕ OpenCode debug config failed: ${truncateExternalDiagnostic(diagnostic)}`;
136
178
  }
137
- let config;
179
+ let sources;
138
180
  try {
139
- config = JSON.parse(result.stdout);
181
+ sources = parseOpenCodeConfig(result.stdout);
140
182
  }
141
- catch {
142
- return '✕ OpenCode debug config returned invalid JSON';
183
+ catch (error) {
184
+ return `✕ ${error.message}`;
143
185
  }
144
- const plugin = config.config?.plugin;
145
- const expected = SQUARE_IDENTITY.packageName;
146
- if (Array.isArray(plugin) && plugin.includes(expected))
147
- return '✓ OpenCode npm plugin loaded';
148
- return `○ OpenCode npm plugin not loaded: ${expected}`;
186
+ const configured = sources.some((source) => source.type === 'document'
187
+ && Array.isArray(source.info?.plugins) && source.info.plugins.some((entry) => openCodePluginSpec(entry) !== undefined));
188
+ return configured
189
+ ? '✓ OpenCode npm plugin configured (runtime loading not verified)'
190
+ : `○ OpenCode npm plugin not configured: ${SQUARE_IDENTITY.packageName}`;
149
191
  }
150
192
  catch (error) {
151
193
  const diagnostic = error instanceof Error ? error.message : String(error);
152
194
  return `○ OpenCode runtime unavailable (${truncateExternalDiagnostic(diagnostic)})`;
153
195
  }
154
196
  }
155
- export function skillLinks(homeDir = os.homedir(), parents = ['.claude', '.agents']) {
156
- return parents.flatMap((parent) => ['square'].map((name) => ({
157
- source: path.join(packageRoot(), 'skills', name),
158
- target: path.join(homeDir, parent, 'skills', name),
159
- kind: 'skill',
160
- })));
161
- }
package/dist/harness.js CHANGED
@@ -5,7 +5,7 @@ import { truncateExternalDiagnostic } from './presentation.js';
5
5
  import { doctorClaudePlugin, installClaudePlugin, uninstallClaudePlugin, } from './harness-claude.js';
6
6
  import { doctorCodexPlugin, installCodexPlugin, uninstallCodexPlugin, } from './harness-codex.js';
7
7
  import { doctorPiPackage, installPiPackage, uninstallPiPackage, } from './harness-pi.js';
8
- import { doctorHarnessLinks, installHarnessLinks, installOpenCodePlugin, skillLinks, uninstallOpenCodePlugin, uninstallHarnessLinks, verifyOpenCodeRuntime, } from './harness-links.js';
8
+ import { installOpenCodePlugin, uninstallOpenCodePlugin, verifyOpenCodeRuntime, } from './harness-links.js';
9
9
  function result(lines, notes = []) {
10
10
  return { lines, notes };
11
11
  }
@@ -18,9 +18,6 @@ async function doctorHost(label, inspect) {
18
18
  return result([`○ ${label} doctor unavailable (${truncateExternalDiagnostic(diagnostic)})`]);
19
19
  }
20
20
  }
21
- function openCodeLinks(homeDir) {
22
- return skillLinks(homeDir, ['.agents']);
23
- }
24
21
  function readableSquarePath(squarePath) {
25
22
  if (squarePath === undefined)
26
23
  return false;
@@ -67,15 +64,9 @@ const TARGETS = [
67
64
  {
68
65
  name: 'opencode',
69
66
  capabilities: ['install', 'uninstall', 'doctor'],
70
- install: async ({ homeDir, force }) => result([
71
- ...installOpenCodePlugin(homeDir, force),
72
- ...installHarnessLinks(openCodeLinks(homeDir), force),
73
- ]),
74
- uninstall: ({ homeDir }) => result([
75
- ...uninstallOpenCodePlugin(homeDir),
76
- ...uninstallHarnessLinks(openCodeLinks(homeDir)),
77
- ]),
78
- doctor: ({ homeDir }) => result([verifyOpenCodeRuntime(homeDir), ...doctorHarnessLinks(openCodeLinks(homeDir))]),
67
+ install: ({ homeDir, force }) => result(installOpenCodePlugin(homeDir, force)),
68
+ uninstall: ({ homeDir }) => result(uninstallOpenCodePlugin(homeDir)),
69
+ doctor: ({ homeDir }) => result([verifyOpenCodeRuntime(homeDir)]),
79
70
  },
80
71
  {
81
72
  name: 'pi',
package/dist/mcp.js CHANGED
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { SQUARE_TOOLS, SQUARE_TOOL_INSTRUCTIONS, squareToolFailure, squareToolFailureText } from './square-tools.js';
4
4
  import { operationEnv } from './participant-identity.js';
5
+ import { codexWakeAddress } from './codex-address.js';
5
6
  import { readClaudeMcpCall } from './claude-mcp-context.js';
6
7
  import { SQUARE_IDENTITY } from './identity.js';
7
8
  import { Square, createSquareResourceOwner } from './square-api.js';
@@ -46,7 +47,7 @@ export function createSquareMcpServer(options) {
46
47
  ...options, env, body, cwd: claude?.cwd ?? options.cwd,
47
48
  // A call-local Codex thread uses its native queue even when managed by Paseo.
48
49
  wakeAddress: claude === undefined && typeof threadId === 'string' && threadId.trim()
49
- ? { kind: 'codex-queue', address: { threadId } } : options.wakeAddress,
50
+ ? codexWakeAddress(threadId, env) : options.wakeAddress,
50
51
  path: location ?? options.path,
51
52
  participant: participant ?? options.participant,
52
53
  // The server owner is bound to its configured square; overrides close after each call.
@@ -0,0 +1,13 @@
1
+ export interface CodexBoundary {
2
+ readonly lastStop: number;
3
+ readonly lastNonStop: number;
4
+ readonly ended?: true;
5
+ }
6
+ export interface CodexBoundaryStore {
7
+ 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
+ }
12
+ /** Caller-owned observation file, shared by independent hook and sender processes. */
13
+ export declare function createCodexBoundaryStore(file: string): CodexBoundaryStore;
@@ -0,0 +1,75 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
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. */
6
+ export function createCodexBoundaryStore(file) {
7
+ const load = async () => {
8
+ let raw;
9
+ try {
10
+ raw = await fs.readFile(file, 'utf8');
11
+ }
12
+ catch (error) {
13
+ if (error.code === 'ENOENT')
14
+ return empty();
15
+ throw error;
16
+ }
17
+ try {
18
+ const value = JSON.parse(raw);
19
+ if (value?.v !== 1 || !Number.isSafeInteger(value.nextSequence) || value.nextSequence < 0 ||
20
+ !value.threads || typeof value.threads !== 'object')
21
+ return empty();
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;
29
+ }
30
+ return result;
31
+ }
32
+ catch {
33
+ return empty();
34
+ }
35
+ };
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
+ const read = async (id) => id ? (await load()).threads[id] : undefined;
50
+ return {
51
+ read,
52
+ async queueEligible(id) {
53
+ const boundary = await read(id);
54
+ return boundary !== undefined && boundary.ended !== true && boundary.lastStop > boundary.lastNonStop;
55
+ },
56
+ async record(id, event, signal) {
57
+ if (!id)
58
+ 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')
63
+ 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);
71
+ },
72
+ async clear(id, signal) { if (id)
73
+ await update((state) => { delete state.threads[id]; }, signal); },
74
+ };
75
+ }
@@ -0,0 +1,10 @@
1
+ import type { CodexCliConnectOptions, CodexSendTextOptions } from './types.js';
2
+ /** Bind once; no subprocess or lifecycle read until queue(). The receiver's
3
+ * hooks and this client share one lifecycle source, never a Square default. */
4
+ export declare function connectCodexCli(options: CodexCliConnectOptions): Promise<{
5
+ coordinate: string;
6
+ send(text: string, value: CodexSendTextOptions): Promise<{
7
+ state: string;
8
+ inputId: `${string}-${string}-${string}-${string}-${string}`;
9
+ }>;
10
+ }>;
@@ -0,0 +1,69 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { resolveCodexHome } from './codex-endpoint.js';
3
+ import { sendCodexQueue, CodexQueueSendError } from './codex-queue.js';
4
+ import { deadline, DEFAULT_TIMEOUT_MS, validSignal, validTimeout } from './deadline.js';
5
+ import { ConnectionError, DeliveryError } from './errors.js';
6
+ import { isIdentity, isRecord } from './guards.js';
7
+ /** Bind once; no subprocess or lifecycle read until queue(). The receiver's
8
+ * hooks and this client share one lifecycle source, never a Square default. */
9
+ export async function connectCodexCli(options) {
10
+ const text = (value) => isIdentity(value) && !value.includes('\0');
11
+ if (!isRecord(options) || options.harness !== 'codex' || options.transport !== 'cli' ||
12
+ !text(options.sessionId) || !isRecord(options.boundaries) || typeof options.boundaries.queueEligible !== 'function' ||
13
+ !validTimeout(options.timeoutMs) || !validSignal(options.signal) ||
14
+ (options.codexHome !== undefined && !text(options.codexHome)) ||
15
+ (options.endpoint !== undefined && !text(options.endpoint)) || (options.bin !== undefined && !text(options.bin)) ||
16
+ (options.args !== undefined && (!Array.isArray(options.args) || options.args.some((arg) => typeof arg !== 'string' || arg.includes('\0')))) ||
17
+ (options.env !== undefined && (!isRecord(options.env) || Object.entries(options.env).some(([key, v]) => key.includes('\0') || (v !== undefined && (typeof v !== 'string' || v.includes('\0'))))))) {
18
+ throw new ConnectionError('invalid_arguments');
19
+ }
20
+ if (options.signal?.aborted)
21
+ throw new ConnectionError('aborted');
22
+ const env = { ...(options.env ?? process.env) };
23
+ try {
24
+ env.CODEX_HOME = resolveCodexHome(options.codexHome === undefined ? env : { ...env, CODEX_HOME: options.codexHome });
25
+ }
26
+ catch {
27
+ throw new ConnectionError('invalid_arguments');
28
+ }
29
+ const sessionId = options.sessionId;
30
+ const eligible = options.boundaries.queueEligible.bind(options.boundaries);
31
+ const cli = { env, bin: options.bin, args: options.args === undefined ? undefined : [...options.args], endpoint: options.endpoint };
32
+ return {
33
+ coordinate: sessionId,
34
+ async send(text, value) {
35
+ if (typeof text !== 'string' || !text.trim() || text.includes('\0') || !isRecord(value) ||
36
+ value.delivery !== 'queue' || value.inputId !== undefined || !validTimeout(value.timeoutMs) || !validSignal(value.signal)) {
37
+ throw new TypeError('Invalid Codex queue arguments.');
38
+ }
39
+ const inputId = randomUUID(); // Local attempt correlation; the CLI does not accept a client message id.
40
+ const scope = deadline(value.timeoutMs, value.signal);
41
+ const until = Date.now() + (value.timeoutMs ?? DEFAULT_TIMEOUT_MS);
42
+ let launched = false;
43
+ try {
44
+ scope.check();
45
+ // One check, immediately before launching the CLI. SessionEnd/late Stop
46
+ // suppression belongs to the bound source, not every sending caller.
47
+ if (!await scope.wait(eligible(sessionId)))
48
+ throw new DeliveryError('session_inactive', false, inputId);
49
+ scope.check();
50
+ const remaining = until - Date.now();
51
+ if (remaining <= 0)
52
+ throw new DeliveryError('timeout', false, inputId);
53
+ launched = true;
54
+ await sendCodexQueue({ threadId: sessionId, message: text }, { ...cli, timeoutMs: remaining, signal: scope.signal });
55
+ return { state: 'admitted', inputId };
56
+ }
57
+ catch (error) {
58
+ if (error instanceof DeliveryError)
59
+ throw error;
60
+ if (error instanceof CodexQueueSendError)
61
+ throw new DeliveryError(scope.code ?? error.code, error.maybeDelivered, inputId);
62
+ throw new DeliveryError(scope.code ?? (launched ? 'transport' : 'unavailable'), launched, inputId);
63
+ }
64
+ finally {
65
+ scope.close();
66
+ }
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,3 @@
1
+ /** Capture the recipient's home before another process delivers. No service discovery I/O. */
2
+ export declare function resolveCodexHome(env?: NodeJS.ProcessEnv): string;
3
+ export declare function codexQueueEndpoint(env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,14 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ /** Capture the recipient's home before another process delivers. No service discovery I/O. */
4
+ export function resolveCodexHome(env = process.env) {
5
+ const userHome = env.HOME || os.homedir();
6
+ const raw = env.CODEX_HOME || path.join(userHome, '.codex');
7
+ const home = raw === '~' ? userHome : raw.startsWith('~/') ? path.join(userHome, raw.slice(2)) : raw;
8
+ if (!path.isAbsolute(home) || home.includes('\0'))
9
+ throw new TypeError('Invalid Codex home.');
10
+ return home;
11
+ }
12
+ export function codexQueueEndpoint(env = process.env) {
13
+ return `unix://${path.join(resolveCodexHome(env), 'app-server-control', 'app-server-control.sock')}`;
14
+ }
@@ -0,0 +1,26 @@
1
+ import type { CodexBoundaryStore } from './codex-boundary.js';
2
+ export { createCodexBoundaryStore } from './codex-boundary.js';
3
+ export type { CodexBoundary, CodexBoundaryStore } from './codex-boundary.js';
4
+ export interface CodexHookResponse {
5
+ readonly hookSpecificOutput: {
6
+ readonly hookEventName: 'PostToolUse';
7
+ readonly additionalContext: string;
8
+ };
9
+ }
10
+ export interface CodexHookOptions {
11
+ 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>;
19
+ readonly end?: (sessionId: string, signal: AbortSignal) => Promise<void>;
20
+ readonly trace?: (stage: string, detail?: string) => void;
21
+ readonly timeoutMs?: number;
22
+ readonly sweepTimeoutMs?: number;
23
+ readonly signal?: AbortSignal;
24
+ }
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>;
@@ -0,0 +1,58 @@
1
+ import { deadline, validSignal, validTimeout } from './deadline.js';
2
+ import { isRecord } from './guards.js';
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())
7
+ return;
8
+ const event = input.hook_event_name;
9
+ if (!['SessionStart', 'SessionResume', 'UserPromptSubmit', 'SessionEnd', 'PostToolUse', 'Stop'].includes(event))
10
+ return;
11
+ if (!validTimeout(options.timeoutMs) || !validTimeout(options.sweepTimeoutMs) || !validSignal(options.signal)) {
12
+ throw new TypeError('Invalid hook control.');
13
+ }
14
+ 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;
17
+ try {
18
+ 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));
25
+ options.trace?.('boundary-record');
26
+ if (event !== 'Stop' && event !== 'PostToolUse')
27
+ 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
+ }
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(() => { });
42
+ }
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 } };
48
+ }
49
+ }
50
+ catch (error) {
51
+ if (scope.signal.aborted)
52
+ throw scope.signal.reason;
53
+ throw error;
54
+ }
55
+ finally {
56
+ scope.close();
57
+ }
58
+ }
@@ -0,0 +1,24 @@
1
+ export { codexQueueEndpoint } from './codex-endpoint.js';
2
+ import type { DeliveryErrorCode } from './errors.js';
3
+ export interface CodexQueueRequest {
4
+ readonly threadId: string;
5
+ readonly message: string;
6
+ }
7
+ export interface CodexQueueOptions {
8
+ readonly bin?: string;
9
+ readonly args?: readonly string[];
10
+ readonly env?: NodeJS.ProcessEnv;
11
+ readonly timeoutMs?: number;
12
+ readonly signal?: AbortSignal;
13
+ readonly endpoint?: string;
14
+ }
15
+ export type CodexQueueFailureKind = 'transient' | 'rejected' | 'unknown';
16
+ /** Once the child starts, a timeout or unexplained exit does not establish non-delivery. */
17
+ export declare class CodexQueueSendError extends Error {
18
+ readonly kind: CodexQueueFailureKind;
19
+ readonly code: DeliveryErrorCode;
20
+ constructor(kind: CodexQueueFailureKind, code: DeliveryErrorCode);
21
+ get maybeDelivered(): boolean;
22
+ }
23
+ /** Internal CLI transport. Lifecycle checking belongs to the bound CLI client. */
24
+ export declare function sendCodexQueue(request: CodexQueueRequest, options?: CodexQueueOptions): Promise<void>;