@addai/node 0.30.10 → 0.30.12

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.
@@ -12,6 +12,7 @@ const child_process_1 = require("child_process");
12
12
  const claude_binary_1 = require("./claude-binary");
13
13
  const win_1 = require("./win");
14
14
  const claude_system_prompt_1 = require("./claude-system-prompt");
15
+ const prompt_transport_1 = require("./prompt-transport");
15
16
  /**
16
17
  * The text to emit for one flushed block, given what came before.
17
18
  *
@@ -175,17 +176,20 @@ function spawnClaudePrint(input) {
175
176
  // its node script instead (identity on POSIX). It can throw; so can
176
177
  // spawn() itself (synchronously, on win32). Surface either as a normal
177
178
  // rejected `done` so callers keep their single error path.
178
- // An entity system prompt of tens of thousands of characters does not fit
179
- // on a Windows command line — see claude-system-prompt.ts. No-op when it
180
- // fits, which is every case that works today.
181
- const sysPrompt = (0, claude_system_prompt_1.offloadLongSystemPrompt)(bin, args);
179
+ // One path, every run: the system prompt travels as a file and the user
180
+ // prompt down stdin, whatever their size. Size-dependent branches are where
181
+ // the ENAMETOOLONG failures hid — see prompt-transport.ts. The file form is
182
+ // gated only on this claude knowing the flag (probed once per binary).
183
+ const isWindows = process.platform === 'win32';
184
+ const sysPrompt = (0, claude_system_prompt_1.offloadLongSystemPrompt)(bin, args, isWindows, (0, claude_system_prompt_1.claudeSupportsPromptFile)(bin));
185
+ const transport = (0, prompt_transport_1.offloadLongPrompt)(bin, sysPrompt.args, sysPrompt.args.length - 1, null, isWindows, true);
182
186
  let proc;
183
187
  try {
184
- const inv = (0, win_1.resolveCliInvocation)(bin, sysPrompt.args);
188
+ const inv = (0, win_1.resolveCliInvocation)(bin, transport.args);
185
189
  proc = (0, child_process_1.spawn)(inv.file, inv.args, {
186
190
  cwd: input.workingDirectory,
187
191
  env: { ...process.env, TERM: 'dumb' },
188
- stdio: ['ignore', 'pipe', 'pipe'],
192
+ stdio: (0, prompt_transport_1.stdioFor)(transport.stdin),
189
193
  // No console window. This is headless work on somebody's machine —
190
194
  // on Windows a spawn without this flashes a terminal every run.
191
195
  windowsHide: true,
@@ -199,6 +203,7 @@ function spawnClaudePrint(input) {
199
203
  done.catch(() => { });
200
204
  return { pid: undefined, sessionId: null, kill: () => { }, onEvent: () => { }, done };
201
205
  }
206
+ (0, prompt_transport_1.feedStdin)(proc, transport.stdin);
202
207
  const listeners = [];
203
208
  const emit = (e) => { for (const l of listeners)
204
209
  try {
@@ -1,3 +1,9 @@
1
+ /** Head-room under the real 32,767 limit. Both node and node-pty escape quotes
2
+ * and backslashes when they render argv into a command line, so what actually
3
+ * reaches CreateProcess is longer than the sum of the arguments. */
4
+ export declare const WINDOWS_BUDGET = 30000;
5
+ /** POSIX only needs catching if a prompt has gone genuinely pathological. */
6
+ export declare const POSIX_BUDGET = 120000;
1
7
  /** Rough rendered length of `file` + `args` as one command line. Counts two
2
8
  * quotes and a separating space per argument — the shape a renderer uses for
3
9
  * anything that might contain whitespace. */
@@ -15,4 +21,9 @@ export interface OffloadResult {
15
21
  * the temp file cannot be written — in that last case the spawn is left to
16
22
  * fail with its own error rather than a worse one invented here.
17
23
  */
18
- export declare function offloadLongSystemPrompt(file: string, args: string[], isWindows?: boolean): OffloadResult;
24
+ export declare function offloadLongSystemPrompt(file: string, args: string[], isWindows?: boolean,
25
+ /** Move it regardless of size — the one-path rule (see prompt-transport.ts).
26
+ * Callers pass claudeSupportsPromptFile(bin) so an older claude that does
27
+ * not know the -file flag keeps the inline form. */
28
+ force?: boolean): OffloadResult;
29
+ export declare function claudeSupportsPromptFile(bin: string, probe?: (bin: string) => string): boolean;
@@ -14,6 +14,10 @@
14
14
  // --append-system-prompt-file. ONLY when it would not fit: the inline form is
15
15
  // what every working install uses today and an older claude may not know the
16
16
  // -file variant, so nothing that currently works changes shape.
17
+ //
18
+ // This only covers the system prompt. The USER prompt can be too long on its
19
+ // own (a fresh session's context refresh carries the recent conversation);
20
+ // prompt-transport.ts moves that one onto stdin, after this has run.
17
21
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
22
  if (k2 === undefined) k2 = k;
19
23
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -48,20 +52,24 @@ var __importStar = (this && this.__importStar) || (function () {
48
52
  };
49
53
  })();
50
54
  Object.defineProperty(exports, "__esModule", { value: true });
55
+ exports.POSIX_BUDGET = exports.WINDOWS_BUDGET = void 0;
51
56
  exports.commandLineLength = commandLineLength;
52
57
  exports.offloadLongSystemPrompt = offloadLongSystemPrompt;
58
+ exports.claudeSupportsPromptFile = claudeSupportsPromptFile;
53
59
  const fs = __importStar(require("fs"));
54
60
  const os = __importStar(require("os"));
55
61
  const path = __importStar(require("path"));
56
62
  const crypto_1 = require("crypto");
63
+ const child_process_1 = require("child_process");
64
+ const win_1 = require("./win");
57
65
  const INLINE_FLAG = '--append-system-prompt';
58
66
  const FILE_FLAG = '--append-system-prompt-file';
59
67
  /** Head-room under the real 32,767 limit. Both node and node-pty escape quotes
60
68
  * and backslashes when they render argv into a command line, so what actually
61
69
  * reaches CreateProcess is longer than the sum of the arguments. */
62
- const WINDOWS_BUDGET = 30_000;
70
+ exports.WINDOWS_BUDGET = 30_000;
63
71
  /** POSIX only needs catching if a prompt has gone genuinely pathological. */
64
- const POSIX_BUDGET = 120_000;
72
+ exports.POSIX_BUDGET = 120_000;
65
73
  /** Rough rendered length of `file` + `args` as one command line. Counts two
66
74
  * quotes and a separating space per argument — the shape a renderer uses for
67
75
  * anything that might contain whitespace. */
@@ -76,10 +84,14 @@ function commandLineLength(file, args) {
76
84
  * the temp file cannot be written — in that last case the spawn is left to
77
85
  * fail with its own error rather than a worse one invented here.
78
86
  */
79
- function offloadLongSystemPrompt(file, args, isWindows = process.platform === 'win32') {
87
+ function offloadLongSystemPrompt(file, args, isWindows = process.platform === 'win32',
88
+ /** Move it regardless of size — the one-path rule (see prompt-transport.ts).
89
+ * Callers pass claudeSupportsPromptFile(bin) so an older claude that does
90
+ * not know the -file flag keeps the inline form. */
91
+ force = false) {
80
92
  const noop = { args, cleanup: () => { } };
81
- const budget = isWindows ? WINDOWS_BUDGET : POSIX_BUDGET;
82
- if (commandLineLength(file, args) <= budget)
93
+ const budget = isWindows ? exports.WINDOWS_BUDGET : exports.POSIX_BUDGET;
94
+ if (!force && commandLineLength(file, args) <= budget)
83
95
  return noop;
84
96
  const i = args.indexOf(INLINE_FLAG);
85
97
  if (i < 0 || i + 1 >= args.length)
@@ -108,3 +120,35 @@ function offloadLongSystemPrompt(file, args, isWindows = process.platform === 'w
108
120
  },
109
121
  };
110
122
  }
123
+ /**
124
+ * Does this claude know --append-system-prompt-file? Asked once per binary
125
+ * and remembered for the life of the daemon. The answer decides whether the
126
+ * system prompt ALWAYS travels as a file — one path, exercised by every run —
127
+ * or only when it would not fit inline, the old rare path kept for a claude
128
+ * too old to know the flag. A probe that fails answers "no", which is the
129
+ * behaviour every working install had before.
130
+ */
131
+ const promptFileSupport = new Map();
132
+ function claudeSupportsPromptFile(bin, probe = claudeHelpText) {
133
+ const cached = promptFileSupport.get(bin);
134
+ if (cached !== undefined)
135
+ return cached;
136
+ let ok = false;
137
+ // claude's own help lists the pair as `--append-system-prompt[-file]`, so a
138
+ // literal search for the long flag would say no to a claude that knows it.
139
+ try {
140
+ const help = probe(bin);
141
+ ok = help.includes(FILE_FLAG) || help.includes('--append-system-prompt[-file]');
142
+ }
143
+ catch {
144
+ ok = false;
145
+ }
146
+ promptFileSupport.set(bin, ok);
147
+ return ok;
148
+ }
149
+ function claudeHelpText(bin) {
150
+ const inv = (0, win_1.resolveCliInvocation)(bin, ['--help']);
151
+ return (0, child_process_1.execFileSync)(inv.file, inv.args, {
152
+ encoding: 'utf8', timeout: 20_000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
153
+ });
154
+ }
@@ -25,14 +25,43 @@ export interface CodexInput {
25
25
  * Mirrors the claude --strict-mcp-config behavior. */
26
26
  mcpServers?: CodexMcpServer[];
27
27
  }
28
+ export interface CodexHomeOptions {
29
+ /** Forward a remote server's credentials as headers via env_http_headers.
30
+ * spawnCodex passes codexSupportsEnvHttpHeaders(bin), so a codex too old to
31
+ * know the key keeps exactly the config it had before. Default true. */
32
+ envHttpHeaders?: boolean;
33
+ }
28
34
  /** Build a config.toml that declares only the supplied MCP servers and
29
35
  * return the CODEX_HOME directory plus any env vars the child needs
30
- * (bearer tokens for remote servers). Caller is responsible for cleanup
31
- * (we drop it under os.tmpdir() so it gets reaped). */
32
- export declare function writeCodexHome(servers: CodexMcpServer[], workingDirectory: string): {
36
+ * (bearer tokens and header values for remote servers). Caller is
37
+ * responsible for cleanup (we drop it under os.tmpdir() so it gets reaped). */
38
+ export declare function writeCodexHome(servers: CodexMcpServer[], workingDirectory: string, opts?: CodexHomeOptions): {
33
39
  home: string;
34
40
  extraEnv: Record<string, string>;
35
41
  };
42
+ /**
43
+ * Does this codex know env_http_headers? Asked by having it parse a throwaway
44
+ * config (`codex mcp list --json`, which reads config and contacts nothing)
45
+ * and checking that the header survived. Remembered per binary. Any failure
46
+ * answers "no", which keeps the bearer-only config every install ran before.
47
+ */
48
+ export declare function codexSupportsEnvHttpHeaders(bin: string, probe?: (bin: string, codexHome: string) => string): boolean;
49
+ /** Read `codex mcp list --json` output: did the probe server's header survive?
50
+ * Exported for tests. */
51
+ export declare function codexListShowsEnvHeaders(stdout: string): boolean;
52
+ /** First line of an AGENTS.md this daemon wrote. A project's own AGENTS.md
53
+ * never carries it and is never touched. */
54
+ export declare const AGENTS_MD_MARKER = "<!-- ainode: entity system prompt \u2014 rewritten before every turn; edits here do not persist -->";
55
+ /**
56
+ * Put the entity's system prompt where codex natively looks for guidance:
57
+ * AGENTS.md in the working directory. codex has no --append-system-prompt,
58
+ * and gluing 30K characters of persona onto every user turn was the
59
+ * alternative. Returns false — and writes nothing — when the folder already
60
+ * has an AGENTS.md that is not ours, so a chat bound to a real repository
61
+ * can never have its project file overwritten; the caller then falls back to
62
+ * the glued form.
63
+ */
64
+ export declare function writeCodexAgentsMd(workingDirectory: string, systemPrompt: string): boolean;
36
65
  export interface CodexHandle {
37
66
  pid: number | undefined;
38
67
  threadId: string | null;
@@ -40,7 +40,11 @@ var __importStar = (this && this.__importStar) || (function () {
40
40
  };
41
41
  })();
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.AGENTS_MD_MARKER = void 0;
43
44
  exports.writeCodexHome = writeCodexHome;
45
+ exports.codexSupportsEnvHttpHeaders = codexSupportsEnvHttpHeaders;
46
+ exports.codexListShowsEnvHeaders = codexListShowsEnvHeaders;
47
+ exports.writeCodexAgentsMd = writeCodexAgentsMd;
44
48
  exports.codexEffort = codexEffort;
45
49
  exports.spawnCodex = spawnCodex;
46
50
  const child_process_1 = require("child_process");
@@ -52,6 +56,8 @@ const codex_binary_1 = require("./codex-binary");
52
56
  const diskguard_1 = require("./diskguard");
53
57
  const win_1 = require("./win");
54
58
  const events_1 = require("./events");
59
+ const prompt_transport_1 = require("./prompt-transport");
60
+ const mcp_headers_1 = require("./mcp-headers");
55
61
  /** Quote a string as a TOML basic string (double-quoted, escape backslash + quote). */
56
62
  function tomlString(s) {
57
63
  return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
@@ -60,11 +66,17 @@ function tomlString(s) {
60
66
  * Only the NAME goes in config.toml — the value rides on the child env, so
61
67
  * the token never lands on disk. */
62
68
  const bearerEnvVar = (slug) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_TOKEN`;
69
+ /** Env var name carrying one HTTP header's value into the codex process.
70
+ * config.toml references it by name via env_http_headers, so the value never
71
+ * lands on disk either. */
72
+ const headerEnvVar = (slug, header) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}` +
73
+ `_H_${header.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`;
63
74
  /** Build a config.toml that declares only the supplied MCP servers and
64
75
  * return the CODEX_HOME directory plus any env vars the child needs
65
- * (bearer tokens for remote servers). Caller is responsible for cleanup
66
- * (we drop it under os.tmpdir() so it gets reaped). */
67
- function writeCodexHome(servers, workingDirectory) {
76
+ * (bearer tokens and header values for remote servers). Caller is
77
+ * responsible for cleanup (we drop it under os.tmpdir() so it gets reaped). */
78
+ function writeCodexHome(servers, workingDirectory, opts = {}) {
79
+ const envHttpHeaders = opts.envHttpHeaders !== false;
68
80
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'entities-codex-'));
69
81
  const extraEnv = {};
70
82
  let toml = '# Generated by ainode — sandboxes codex against host config\n\n';
@@ -89,6 +101,8 @@ function writeCodexHome(servers, workingDirectory) {
89
101
  toml += `url = ${tomlString(url)}\n`;
90
102
  // codex reads the bearer from its OWN environment (--env is stdio-only),
91
103
  // so the value goes on the child env and only the var name in the file.
104
+ // The bearer is exactly what this adapter always sent, kept so anything
105
+ // that works on codex today keeps working.
92
106
  const creds = Object.entries(s.env || {})
93
107
  .filter(([k, v]) => typeof v === 'string' && v.length > 0 && !k.startsWith('OAUTH_REFRESH'));
94
108
  const bearer = creds.find(([k]) => k === 'OAUTH_ACCESS_TOKEN') ?? creds[0];
@@ -96,15 +110,34 @@ function writeCodexHome(servers, workingDirectory) {
96
110
  const varName = bearerEnvVar(s.slug);
97
111
  extraEnv[varName] = String(bearer[1]);
98
112
  toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
99
- if (creds.length > 1) {
100
- // codex carries one bearer and no arbitrary headers. Anything that
101
- // authenticates with several custom headers is only partly wired,
102
- // and saying so beats a server that 401s for no visible reason.
103
- console.error(`[codex] MCP ${s.slug}: only ${bearer[0]} is forwarded as a bearer token; ` +
104
- `codex cannot send custom headers, so ${creds.length - 1} other credential ` +
105
- `field(s) were dropped`);
113
+ }
114
+ // The bearer alone was never enough. The servers read each credential
115
+ // from its own header (the claude path sends them all, see
116
+ // remoteMcpHeaders) and fall back to the bearer for any that is missing,
117
+ // so +Ai Drive, which carries three, used its API key as its workspace
118
+ // id and answered "Access denied to workspace" on every codex run.
119
+ // codex does send custom headers: env_http_headers maps a header name to
120
+ // the NAME of an env var, so the values stay off disk like the bearer.
121
+ const headers = (0, mcp_headers_1.remoteMcpHeaders)(s.env);
122
+ // An OAuth access token is already the bearer above.
123
+ delete headers.Authorization;
124
+ const entries = Object.entries(headers);
125
+ if (entries.length > 0 && envHttpHeaders) {
126
+ // A sub-table must follow the parent table's scalar keys.
127
+ toml += `[mcp_servers.${name}.env_http_headers]\n`;
128
+ for (const [header, value] of entries) {
129
+ const varName = headerEnvVar(s.slug, header);
130
+ extraEnv[varName] = value;
131
+ toml += `${tomlString(header)} = ${tomlString(varName)}\n`;
106
132
  }
107
133
  }
134
+ else if (creds.length > 1) {
135
+ // A codex too old to know env_http_headers carries the bearer only.
136
+ // Saying so beats a server that refuses for no visible reason.
137
+ console.error(`[codex] MCP ${s.slug}: this codex cannot send custom headers, so only ` +
138
+ `${bearer?.[0]} is forwarded and ${creds.length - 1} other credential ` +
139
+ `field(s) were dropped`);
140
+ }
108
141
  toml += '\n';
109
142
  continue;
110
143
  }
@@ -156,6 +189,103 @@ function writeCodexHome(servers, workingDirectory) {
156
189
  }
157
190
  return { home: dir, extraEnv };
158
191
  }
192
+ const PROBE_SERVER = 'ainode_probe';
193
+ const PROBE_HEADER = 'X-Ainode-Probe';
194
+ // A real answer holds until the binary might have been updated; a probe that
195
+ // could not run at all is retried sooner.
196
+ const PROBE_TTL_MS = 6 * 60 * 60 * 1000;
197
+ const PROBE_FAILED_TTL_MS = 10 * 60 * 1000;
198
+ const envHeaderSupport = new Map();
199
+ /**
200
+ * Does this codex know env_http_headers? Asked by having it parse a throwaway
201
+ * config (`codex mcp list --json`, which reads config and contacts nothing)
202
+ * and checking that the header survived. Remembered per binary. Any failure
203
+ * answers "no", which keeps the bearer-only config every install ran before.
204
+ */
205
+ function codexSupportsEnvHttpHeaders(bin, probe = codexMcpListJson) {
206
+ const hit = envHeaderSupport.get(bin);
207
+ if (hit && hit.until > Date.now())
208
+ return hit.ok;
209
+ let ok = false;
210
+ let failed = false;
211
+ let home = null;
212
+ try {
213
+ home = fs.mkdtempSync(path.join(os.tmpdir(), 'ainode-codex-probe-'));
214
+ fs.writeFileSync(path.join(home, 'config.toml'), `[mcp_servers.${PROBE_SERVER}]\n` +
215
+ `url = "http://127.0.0.1:9/mcp"\n` +
216
+ // A bearer keeps codex from looking for stored OAuth credentials.
217
+ `bearer_token_env_var = "AINODE_PROBE_TOKEN"\n` +
218
+ `[mcp_servers.${PROBE_SERVER}.env_http_headers]\n` +
219
+ `"${PROBE_HEADER}" = "AINODE_PROBE_HEADER"\n`, { mode: 0o600 });
220
+ ok = codexListShowsEnvHeaders(probe(bin, home));
221
+ }
222
+ catch {
223
+ ok = false;
224
+ failed = true;
225
+ }
226
+ finally {
227
+ if (home) {
228
+ try {
229
+ fs.rmSync(home, { recursive: true, force: true });
230
+ }
231
+ catch { /* reaped from tmp */ }
232
+ }
233
+ }
234
+ envHeaderSupport.set(bin, { ok, until: Date.now() + (failed ? PROBE_FAILED_TTL_MS : PROBE_TTL_MS) });
235
+ return ok;
236
+ }
237
+ /** Read `codex mcp list --json` output: did the probe server's header survive?
238
+ * Exported for tests. */
239
+ function codexListShowsEnvHeaders(stdout) {
240
+ const start = stdout.search(/^\s*\[/m);
241
+ if (start < 0)
242
+ return false;
243
+ let list;
244
+ try {
245
+ list = JSON.parse(stdout.slice(start));
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ if (!Array.isArray(list))
251
+ return false;
252
+ const entry = list.find((e) => e && e.name === PROBE_SERVER);
253
+ return entry?.transport?.env_http_headers?.[PROBE_HEADER] === 'AINODE_PROBE_HEADER';
254
+ }
255
+ function codexMcpListJson(bin, codexHome) {
256
+ const inv = (0, win_1.resolveCliInvocation)(bin, ['mcp', 'list', '--json']);
257
+ return (0, child_process_1.execFileSync)(inv.file, inv.args, {
258
+ encoding: 'utf8', timeout: 20_000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
259
+ env: { ...process.env, CODEX_HOME: codexHome },
260
+ });
261
+ }
262
+ /** First line of an AGENTS.md this daemon wrote. A project's own AGENTS.md
263
+ * never carries it and is never touched. */
264
+ exports.AGENTS_MD_MARKER = '<!-- ainode: entity system prompt — rewritten before every turn; edits here do not persist -->';
265
+ /**
266
+ * Put the entity's system prompt where codex natively looks for guidance:
267
+ * AGENTS.md in the working directory. codex has no --append-system-prompt,
268
+ * and gluing 30K characters of persona onto every user turn was the
269
+ * alternative. Returns false — and writes nothing — when the folder already
270
+ * has an AGENTS.md that is not ours, so a chat bound to a real repository
271
+ * can never have its project file overwritten; the caller then falls back to
272
+ * the glued form.
273
+ */
274
+ function writeCodexAgentsMd(workingDirectory, systemPrompt) {
275
+ const target = path.join(workingDirectory, 'AGENTS.md');
276
+ try {
277
+ if (fs.existsSync(target)) {
278
+ const head = fs.readFileSync(target, 'utf8').slice(0, exports.AGENTS_MD_MARKER.length + 16);
279
+ if (!head.includes(exports.AGENTS_MD_MARKER))
280
+ return false;
281
+ }
282
+ fs.writeFileSync(target, `${exports.AGENTS_MD_MARKER}\n\n${systemPrompt.trim()}\n`, 'utf8');
283
+ return true;
284
+ }
285
+ catch {
286
+ return false;
287
+ }
288
+ }
159
289
  function lineToEvents(line) {
160
290
  const type = typeof line.type === 'string' ? line.type : '';
161
291
  // Errors come as { type: 'error', message: string } — surface them.
@@ -221,6 +351,10 @@ function spawnCodex(input) {
221
351
  // `exec` command, before the `resume` subcommand. Putting them after
222
352
  // resume returns "unexpected argument" and exits 2.
223
353
  const baseFlags = ['--cd', input.workingDirectory, '--json'];
354
+ // AGENTS.md in the working directory carries the entity's system prompt (see
355
+ // writeCodexAgentsMd). codex truncates project docs at 32 KiB by default,
356
+ // which a persona plus its app instructions routinely exceeds.
357
+ baseFlags.push('-c', 'project_doc_max_bytes=262144');
224
358
  if (input.skipGitRepoCheck)
225
359
  baseFlags.push('--skip-git-repo-check');
226
360
  if (input.bypassPermissions)
@@ -236,10 +370,19 @@ function spawnCodex(input) {
236
370
  const args = input.resumeThreadId
237
371
  ? ['exec', ...baseFlags, 'resume', input.resumeThreadId, input.prompt]
238
372
  : ['exec', ...baseFlags, input.prompt];
373
+ // Resolve binary path before spawn. Previously called spawn('codex', ...)
374
+ // bare, which threw ENOENT under launchd / desktop-app PATH inheritance
375
+ // and rejected `done` before any session_end event fired → request
376
+ // stuck at 'running' on the DB. Resolved first now, because the config
377
+ // below depends on what this codex understands.
378
+ const bin = (0, codex_binary_1.findCodexBinary)();
239
379
  // Sandbox codex against an isolated CODEX_HOME so the host's
240
380
  // ~/.codex/config.toml mcp_servers don't leak in. TERM=dumb keeps
241
381
  // codex's TTY heuristics happy (no fancy terminal output).
242
- const { home: codexHome, extraEnv: mcpBearerEnv } = writeCodexHome(input.mcpServers ?? [], input.workingDirectory);
382
+ const servers = input.mcpServers ?? [];
383
+ // Only a remote server that carries credentials needs the header probe.
384
+ const wantsHeaders = servers.some((s) => (0, mcp_headers_1.isRemoteMcp)(s.command) && Object.keys(s.env ?? {}).length > 0);
385
+ const { home: codexHome, extraEnv: mcpBearerEnv } = writeCodexHome(servers, input.workingDirectory, { envHttpHeaders: wantsHeaders && bin ? codexSupportsEnvHttpHeaders(bin) : false });
243
386
  // Register the scratch dir with DiskGuard: marks it active (so a
244
387
  // concurrent sweep won't evict a live run) and writes a PID lock so a
245
388
  // different runtime process can tell it's still owned. dispose(true)
@@ -250,15 +393,11 @@ function spawnCodex(input) {
250
393
  ...process.env,
251
394
  TERM: 'dumb',
252
395
  CODEX_HOME: codexHome,
253
- // Bearer tokens for remote (http/sse) MCP servers. config.toml names these
254
- // vars via bearer_token_env_var; the values only ever live here.
396
+ // Bearer tokens and header values for remote (http/sse) MCP servers.
397
+ // config.toml names these vars via bearer_token_env_var and
398
+ // env_http_headers; the values only ever live here.
255
399
  ...mcpBearerEnv,
256
400
  };
257
- // Resolve binary path before spawn. Previously called spawn('codex', ...)
258
- // bare, which threw ENOENT under launchd / desktop-app PATH inheritance
259
- // and rejected `done` before any session_end event fired → request
260
- // stuck at 'running' on the DB.
261
- const bin = (0, codex_binary_1.findCodexBinary)();
262
401
  const listeners = [];
263
402
  const emit = (e) => { for (const l of listeners)
264
403
  try {
@@ -292,15 +431,18 @@ function spawnCodex(input) {
292
431
  kill() { },
293
432
  };
294
433
  }
434
+ // One path, every run: the prompt goes down stdin (`codex exec -`, fresh and
435
+ // resume forms alike) whatever its size. See prompt-transport.ts.
436
+ const transport = (0, prompt_transport_1.offloadLongPrompt)(bin, args, args.length - 1, '-', process.platform === 'win32', true);
295
437
  let proc;
296
438
  try {
297
439
  // npm .cmd shim → node script (win32); identity on POSIX. Throws on an
298
440
  // unresolvable shim — caught below alongside spawn's own sync errors.
299
- const inv = (0, win_1.resolveCliInvocation)(bin, args);
441
+ const inv = (0, win_1.resolveCliInvocation)(bin, transport.args);
300
442
  proc = (0, child_process_1.spawn)(inv.file, inv.args, {
301
443
  cwd: input.workingDirectory,
302
444
  env: childEnv,
303
- stdio: ['ignore', 'pipe', 'pipe'],
445
+ stdio: (0, prompt_transport_1.stdioFor)(transport.stdin),
304
446
  // No console window. This is headless work on somebody's machine —
305
447
  // on Windows a spawn without this flashes a terminal every run.
306
448
  windowsHide: true,
@@ -322,6 +464,7 @@ function spawnCodex(input) {
322
464
  kill() { },
323
465
  };
324
466
  }
467
+ (0, prompt_transport_1.feedStdin)(proc, transport.stdin);
325
468
  // Models do not reliably keep reasoning on their own stream — they drop
326
469
  // back to ordinary text mid-run and carry on thinking inside a <think>
327
470
  // tag. Split it back out before it reaches the room. See think-split.ts.
@@ -0,0 +1,29 @@
1
+ /** The one folder we write into. */
2
+ export declare const CONTEXT_DIR = "context";
3
+ export interface ContextFilesResult {
4
+ /** Relative paths written, in the order they arrived. */
5
+ written: string[];
6
+ /** Relative paths refused or failed, with the reason. */
7
+ skipped: Array<{
8
+ path: string;
9
+ reason: string;
10
+ }>;
11
+ }
12
+ /**
13
+ * Validate `files` as a {relative path: markdown} object. Pure; exported for tests.
14
+ * Returns the entries that may be written and the ones that were refused.
15
+ */
16
+ export declare function planContextFiles(files: unknown): {
17
+ ok: Array<[string, string]>;
18
+ refused: Array<{
19
+ path: string;
20
+ reason: string;
21
+ }>;
22
+ };
23
+ /**
24
+ * Write the context files into `cwd`. Never throws.
25
+ *
26
+ * Existing files are replaced whole: each turn carries the current picture of
27
+ * the conversation, and a stale conversation.md is worse than none.
28
+ */
29
+ export declare function writeContextFiles(cwd: string, files: unknown): ContextFilesResult;
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ // Context files: depth the entity can read when it needs it.
3
+ //
4
+ // The dispatcher keeps the inline briefing small — who, where, what has
5
+ // happened here since the last turn — and puts the deeper material into a few
6
+ // markdown files under context/ in the run's working folder: the last 50
7
+ // messages of the conversation, a card about the sender, a card about the
8
+ // chat. They cost nothing until the entity opens one, and every agent CLI can
9
+ // read a local file without a network round trip.
10
+ //
11
+ // The files arrive on the request row as `context_files`: a JSON object of
12
+ // relative path → markdown. Only plain names under context/ are accepted, so a
13
+ // row can never write outside the folder or clobber the agent's own files.
14
+ // The folder is marked as ours on creation (context/.ainode) and ignored by
15
+ // git (context/.gitignore): a chat bound to a real project directory must
16
+ // never gain stray files in the repo, and a project that already HAS a
17
+ // context/ folder of its own is left completely alone.
18
+ //
19
+ // Everything here is best-effort: the inline briefing is complete on its own,
20
+ // so a write failure must never block a turn — it just means the pointer in
21
+ // the prompt leads to a missing file, which the agent reports and moves on.
22
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ var desc = Object.getOwnPropertyDescriptor(m, k);
25
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
26
+ desc = { enumerable: true, get: function() { return m[k]; } };
27
+ }
28
+ Object.defineProperty(o, k2, desc);
29
+ }) : (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ o[k2] = m[k];
32
+ }));
33
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
34
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
35
+ }) : function(o, v) {
36
+ o["default"] = v;
37
+ });
38
+ var __importStar = (this && this.__importStar) || (function () {
39
+ var ownKeys = function(o) {
40
+ ownKeys = Object.getOwnPropertyNames || function (o) {
41
+ var ar = [];
42
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
43
+ return ar;
44
+ };
45
+ return ownKeys(o);
46
+ };
47
+ return function (mod) {
48
+ if (mod && mod.__esModule) return mod;
49
+ var result = {};
50
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
51
+ __setModuleDefault(result, mod);
52
+ return result;
53
+ };
54
+ })();
55
+ Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.CONTEXT_DIR = void 0;
57
+ exports.planContextFiles = planContextFiles;
58
+ exports.writeContextFiles = writeContextFiles;
59
+ const fs = __importStar(require("fs"));
60
+ const path = __importStar(require("path"));
61
+ /** The one folder we write into. */
62
+ exports.CONTEXT_DIR = 'context';
63
+ /** Present only in a context/ folder this daemon created. */
64
+ const OWNER_MARKER = '.ainode';
65
+ /** Relative paths we will write: context/<name>.md, one level, safe characters only. */
66
+ const ALLOWED = /^context\/[A-Za-z0-9][A-Za-z0-9._-]{0,80}\.md$/;
67
+ /** No single file beyond this; the dispatcher caps well under it. */
68
+ const MAX_FILE_CHARS = 200_000;
69
+ /**
70
+ * Validate `files` as a {relative path: markdown} object. Pure; exported for tests.
71
+ * Returns the entries that may be written and the ones that were refused.
72
+ */
73
+ function planContextFiles(files) {
74
+ const ok = [];
75
+ const refused = [];
76
+ if (!files || typeof files !== 'object' || Array.isArray(files))
77
+ return { ok, refused };
78
+ for (const [rel, body] of Object.entries(files)) {
79
+ if (!ALLOWED.test(rel)) {
80
+ refused.push({ path: rel, reason: 'path not allowed' });
81
+ continue;
82
+ }
83
+ if (typeof body !== 'string') {
84
+ refused.push({ path: rel, reason: 'content is not text' });
85
+ continue;
86
+ }
87
+ if (body.length > MAX_FILE_CHARS) {
88
+ refused.push({ path: rel, reason: `over ${MAX_FILE_CHARS} chars` });
89
+ continue;
90
+ }
91
+ ok.push([rel, body]);
92
+ }
93
+ return { ok, refused };
94
+ }
95
+ /**
96
+ * Make sure `<cwd>/context/` exists and is ours. Returns a reason to refuse,
97
+ * or null when it is safe to write there.
98
+ */
99
+ function claimContextDir(cwd) {
100
+ const dir = path.join(cwd, exports.CONTEXT_DIR);
101
+ const marker = path.join(dir, OWNER_MARKER);
102
+ try {
103
+ if (fs.existsSync(dir)) {
104
+ if (!fs.statSync(dir).isDirectory())
105
+ return `${exports.CONTEXT_DIR} exists and is not a directory`;
106
+ if (!fs.existsSync(marker))
107
+ return `${exports.CONTEXT_DIR}/ already exists and was not created by the node`;
108
+ return null;
109
+ }
110
+ fs.mkdirSync(dir, { recursive: true });
111
+ fs.writeFileSync(marker, 'This folder is written by the +Ai Node before every turn. Edits here do not persist.\n');
112
+ fs.writeFileSync(path.join(dir, '.gitignore'), '*\n');
113
+ return null;
114
+ }
115
+ catch (err) {
116
+ return err.message;
117
+ }
118
+ }
119
+ /**
120
+ * Write the context files into `cwd`. Never throws.
121
+ *
122
+ * Existing files are replaced whole: each turn carries the current picture of
123
+ * the conversation, and a stale conversation.md is worse than none.
124
+ */
125
+ function writeContextFiles(cwd, files) {
126
+ const plan = planContextFiles(files);
127
+ const result = { written: [], skipped: [...plan.refused] };
128
+ if (plan.ok.length === 0)
129
+ return result;
130
+ const refusal = claimContextDir(cwd);
131
+ if (refusal) {
132
+ for (const [rel] of plan.ok)
133
+ result.skipped.push({ path: rel, reason: refusal });
134
+ return result;
135
+ }
136
+ for (const [rel, body] of plan.ok) {
137
+ const abs = path.join(cwd, rel);
138
+ try {
139
+ // Write beside, then rename: a reader never sees a half-written file.
140
+ const tmp = `${abs}.${process.pid}.tmp`;
141
+ fs.writeFileSync(tmp, body, 'utf8');
142
+ fs.renameSync(tmp, abs);
143
+ result.written.push(rel);
144
+ }
145
+ catch (err) {
146
+ result.skipped.push({ path: rel, reason: err.message });
147
+ }
148
+ }
149
+ return result;
150
+ }
@@ -54,6 +54,7 @@ const os = __importStar(require("os"));
54
54
  const path = __importStar(require("path"));
55
55
  const gemini_binary_1 = require("./gemini-binary");
56
56
  const win_1 = require("./win");
57
+ const prompt_transport_1 = require("./prompt-transport");
57
58
  const events_1 = require("./events");
58
59
  const think_split_1 = require("./think-split");
59
60
  const mcp_headers_1 = require("./mcp-headers");
@@ -352,6 +353,10 @@ function spawnGemini(input) {
352
353
  // npm .cmd shim → node script (win32); identity on POSIX. Both this
353
354
  // resolution and spawn() itself can throw synchronously on Windows —
354
355
  // fail the request cleanly instead of crashing the daemon.
356
+ // Not yet verified to read the prompt from stdin, so it stays inline — and
357
+ // an oversized command line is refused here with a reason, instead of by
358
+ // the OS with a bare errno. See prompt-transport.ts.
359
+ (0, prompt_transport_1.assertCommandLineFits)('gemini', bin, args);
355
360
  const inv = (0, win_1.resolveCliInvocation)(bin, args);
356
361
  proc = (0, child_process_1.spawn)(inv.file, inv.args, {
357
362
  cwd: input.workingDirectory,
@@ -72,6 +72,7 @@ const os = __importStar(require("os"));
72
72
  const path = __importStar(require("path"));
73
73
  const grok_binary_1 = require("./grok-binary");
74
74
  const win_1 = require("./win");
75
+ const prompt_transport_1 = require("./prompt-transport");
75
76
  const events_1 = require("./events");
76
77
  const think_split_1 = require("./think-split");
77
78
  /** Real config dir for the logged-in account. GROK_HOME points here so the
@@ -344,6 +345,10 @@ function spawnGrok(input) {
344
345
  // npm .cmd shim → node script (win32); identity on POSIX. Both this
345
346
  // resolution and spawn() itself can throw synchronously on Windows —
346
347
  // fail the request cleanly instead of crashing the daemon.
348
+ // Not yet verified to read the prompt from stdin, so it stays inline — and
349
+ // an oversized command line is refused here with a reason, instead of by
350
+ // the OS with a bare errno. See prompt-transport.ts.
351
+ (0, prompt_transport_1.assertCommandLineFits)('grok', bin, args);
347
352
  const inv = (0, win_1.resolveCliInvocation)(bin, args);
348
353
  proc = (0, child_process_1.spawn)(inv.file, inv.args, {
349
354
  cwd: input.workingDirectory,
@@ -70,6 +70,7 @@ const path = __importStar(require("path"));
70
70
  const kimi_binary_1 = require("./kimi-binary");
71
71
  const harness_registry_1 = require("./harness-registry");
72
72
  const win_1 = require("./win");
73
+ const prompt_transport_1 = require("./prompt-transport");
73
74
  const events_1 = require("./events");
74
75
  const think_split_1 = require("./think-split");
75
76
  const mcp_headers_1 = require("./mcp-headers");
@@ -385,6 +386,10 @@ function spawnKimi(input) {
385
386
  // npm .cmd shim → node script (win32); identity on POSIX. Both this
386
387
  // resolution and spawn() itself can throw synchronously on Windows —
387
388
  // fail the request cleanly instead of crashing the daemon.
389
+ // Not yet verified to read the prompt from stdin, so it stays inline — and
390
+ // an oversized command line is refused here with a reason, instead of by
391
+ // the OS with a bare errno. See prompt-transport.ts.
392
+ (0, prompt_transport_1.assertCommandLineFits)('kimi', bin, args);
388
393
  const inv = (0, win_1.resolveCliInvocation)(bin, args);
389
394
  proc = (0, child_process_1.spawn)(inv.file, inv.args, {
390
395
  cwd: input.workingDirectory,
@@ -0,0 +1,47 @@
1
+ import type { ChildProcess } from 'child_process';
2
+ export interface PromptTransport {
3
+ /** argv to spawn with. The very same array when nothing moved. */
4
+ args: string[];
5
+ /** Text to write to the child's stdin and then close it. null = nothing
6
+ * moved; leave stdin ignored exactly as before. */
7
+ stdin: string | null;
8
+ }
9
+ /**
10
+ * Take the prompt at `promptIndex` off the command line.
11
+ *
12
+ * With `force` the prompt always moves (the one-path rule). Without it, it
13
+ * moves only when the rendered command line would not fit the platform's
14
+ * budget — the original behaviour, kept for callers that have no stdin route.
15
+ *
16
+ * `placeholder` is what the CLI wants left in the prompt's place: `'-'` for
17
+ * codex, `null` for claude (which reads stdin only when given no prompt).
18
+ */
19
+ export declare function offloadLongPrompt(file: string, args: string[], promptIndex: number, placeholder: string | null, isWindows?: boolean, force?: boolean): PromptTransport;
20
+ /** stdio for spawn(): a pipe for stdin only when there is a prompt to feed. */
21
+ export declare function stdioFor(stdin: string | null): ['pipe' | 'ignore', 'pipe', 'pipe'];
22
+ /**
23
+ * Write the offloaded prompt to the child's stdin and close it, so the CLI
24
+ * sees EOF and starts. A child that exits before reading (bad binary, an
25
+ * immediate crash) turns the write into EPIPE; that outcome already surfaces
26
+ * through the process's own exit/error path, so the stream error is swallowed
27
+ * here rather than escaping as an unhandled 'error' event that would take the
28
+ * whole daemon down with it.
29
+ */
30
+ export declare function feedStdin(proc: Pick<ChildProcess, 'stdin'>, text: string | null): void;
31
+ /**
32
+ * Would this command line be refused by the operating system? Returns a plain
33
+ * sentence naming the limit that would be hit, or null when it fits.
34
+ *
35
+ * The limits differ in kind, not just size: Windows caps the WHOLE command
36
+ * line (32,767 characters, CreateProcess); Linux caps each SINGLE argument
37
+ * (131,072 bytes, MAX_ARG_STRLEN) and the total at a generous 2 MB; macOS caps
38
+ * the total including the environment at 1 MB, so 900,000 leaves room for it.
39
+ */
40
+ export declare function commandLineProblem(file: string, args: string[], platform?: NodeJS.Platform): string | null;
41
+ /**
42
+ * Refuse, with a reason a person can act on, before the OS refuses with an
43
+ * errno. For launchers whose stdin route is not yet verified. The throw lands
44
+ * in the launcher's existing spawn-failure path, so the provider ladder still
45
+ * moves on to an agent that can take the prompt.
46
+ */
47
+ export declare function assertCommandLineFits(agent: string, file: string, args: string[], platform?: NodeJS.Platform): void;
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ // How a prompt reaches an agent: one path, used on every run.
3
+ //
4
+ // claude-system-prompt.ts moved an oversized --append-system-prompt into a
5
+ // file, and that was enough for Hannah, whose USER prompt was small. It was
6
+ // not enough for Brenda on 10 Sep 2026: a fresh session opened with a
7
+ // "context refresh" plus a global activity diary inside the user prompt —
8
+ // 36,421 characters for one DM, 67,658 for another — and that single argument
9
+ // was on its own over CreateProcess's 32,767-character limit. claude died with
10
+ // `spawn ENAMETOOLONG`, the provider ladder hopped to codex, whose command
11
+ // line carried the system prompt AND the user prompt as one argument, and
12
+ // codex died the same way. The entity posted "spawn ENAMETOOLONG" twice and
13
+ // the run dead-lettered.
14
+ //
15
+ // The rare route is where that bug hid: a size-dependent branch nobody had
16
+ // exercised for the user prompt. So the prompt now ALWAYS goes down the
17
+ // child's stdin for the launchers that read it there — `claude --print` when
18
+ // given no positional prompt, `codex exec` (and `codex exec resume <id>`)
19
+ // when the prompt is `-`. Both were verified on Windows on 10 Sep 2026. Size
20
+ // stops being a thing that can decide whether an entity starts, and the path
21
+ // is tested by every normal turn instead of by the one that overflows.
22
+ //
23
+ // The `force` flag is what makes the route unconditional; without it the
24
+ // helper keeps its original "only when it would not fit" behaviour, which is
25
+ // what the TUI path still uses. Launchers that have NOT been verified to read
26
+ // stdin (grok, kimi, gemini) keep the inline form and get assertCommandLineFits
27
+ // instead: a refusal that names the oversized argument, in place of a bare
28
+ // errno from the OS.
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.offloadLongPrompt = offloadLongPrompt;
31
+ exports.stdioFor = stdioFor;
32
+ exports.feedStdin = feedStdin;
33
+ exports.commandLineProblem = commandLineProblem;
34
+ exports.assertCommandLineFits = assertCommandLineFits;
35
+ const claude_system_prompt_1 = require("./claude-system-prompt");
36
+ /**
37
+ * Take the prompt at `promptIndex` off the command line.
38
+ *
39
+ * With `force` the prompt always moves (the one-path rule). Without it, it
40
+ * moves only when the rendered command line would not fit the platform's
41
+ * budget — the original behaviour, kept for callers that have no stdin route.
42
+ *
43
+ * `placeholder` is what the CLI wants left in the prompt's place: `'-'` for
44
+ * codex, `null` for claude (which reads stdin only when given no prompt).
45
+ */
46
+ function offloadLongPrompt(file, args, promptIndex, placeholder, isWindows = process.platform === 'win32', force = false) {
47
+ const noop = { args, stdin: null };
48
+ if (promptIndex < 0 || promptIndex >= args.length)
49
+ return noop;
50
+ if (!force) {
51
+ const budget = isWindows ? claude_system_prompt_1.WINDOWS_BUDGET : claude_system_prompt_1.POSIX_BUDGET;
52
+ if ((0, claude_system_prompt_1.commandLineLength)(file, args) <= budget)
53
+ return noop;
54
+ }
55
+ const prompt = args[promptIndex];
56
+ const next = args.slice();
57
+ if (placeholder === null)
58
+ next.splice(promptIndex, 1);
59
+ else
60
+ next[promptIndex] = placeholder;
61
+ return { args: next, stdin: prompt };
62
+ }
63
+ /** stdio for spawn(): a pipe for stdin only when there is a prompt to feed. */
64
+ function stdioFor(stdin) {
65
+ return [stdin === null ? 'ignore' : 'pipe', 'pipe', 'pipe'];
66
+ }
67
+ /**
68
+ * Write the offloaded prompt to the child's stdin and close it, so the CLI
69
+ * sees EOF and starts. A child that exits before reading (bad binary, an
70
+ * immediate crash) turns the write into EPIPE; that outcome already surfaces
71
+ * through the process's own exit/error path, so the stream error is swallowed
72
+ * here rather than escaping as an unhandled 'error' event that would take the
73
+ * whole daemon down with it.
74
+ */
75
+ function feedStdin(proc, text) {
76
+ if (text === null)
77
+ return;
78
+ const stdin = proc.stdin;
79
+ if (!stdin)
80
+ return;
81
+ stdin.on('error', () => { });
82
+ stdin.end(text, 'utf8');
83
+ }
84
+ /**
85
+ * Would this command line be refused by the operating system? Returns a plain
86
+ * sentence naming the limit that would be hit, or null when it fits.
87
+ *
88
+ * The limits differ in kind, not just size: Windows caps the WHOLE command
89
+ * line (32,767 characters, CreateProcess); Linux caps each SINGLE argument
90
+ * (131,072 bytes, MAX_ARG_STRLEN) and the total at a generous 2 MB; macOS caps
91
+ * the total including the environment at 1 MB, so 900,000 leaves room for it.
92
+ */
93
+ function commandLineProblem(file, args, platform = process.platform) {
94
+ const total = (0, claude_system_prompt_1.commandLineLength)(file, args);
95
+ const longest = args.reduce((m, a) => Math.max(m, a.length), 0);
96
+ if (platform === 'win32') {
97
+ return total > 32_767
98
+ ? `the command line would be ${total} characters and Windows allows 32,767`
99
+ : null;
100
+ }
101
+ if (platform === 'linux' && longest > 131_072) {
102
+ return `one argument would be ${longest} characters and Linux allows 131,072 per argument`;
103
+ }
104
+ if (total > 900_000) {
105
+ return `the command line would be ${total} characters and this OS allows about 1,000,000 including the environment`;
106
+ }
107
+ return null;
108
+ }
109
+ /**
110
+ * Refuse, with a reason a person can act on, before the OS refuses with an
111
+ * errno. For launchers whose stdin route is not yet verified. The throw lands
112
+ * in the launcher's existing spawn-failure path, so the provider ladder still
113
+ * moves on to an agent that can take the prompt.
114
+ */
115
+ function assertCommandLineFits(agent, file, args, platform = process.platform) {
116
+ const problem = commandLineProblem(file, args, platform);
117
+ if (!problem)
118
+ return;
119
+ throw new Error(`${agent} cannot be started: ${problem}. The prompt is too long to pass on the command line ` +
120
+ `and this launcher does not yet read it from stdin; the provider ladder will try the next agent.`);
121
+ }
@@ -60,6 +60,8 @@ export interface RuntimeRequestRow {
60
60
  * tokens before SKILL.md is written. Optional — older rows omit it and
61
61
  * the daemon defaults to {} (no substitution beyond skill defaults). */
62
62
  skill_var_values?: Record<string, Record<string, string>> | null;
63
+ /** Conversation context files {relative path: markdown} to write into cwd before spawn (context-files.ts). */
64
+ context_files?: Record<string, string> | null;
63
65
  /** Self-improve flag for autonomous entities. When truthy AND the run
64
66
  * reaches a `completed` terminal status, the daemon fires a best-effort
65
67
  * POST to the `entity-self-improve` edge function so the entity can
@@ -75,6 +75,7 @@ const hang_watchdog_1 = require("./hang-watchdog");
75
75
  const install_1 = require("./install");
76
76
  const memory_capture_1 = require("./memory-capture");
77
77
  const memory_pack_1 = require("./memory-pack");
78
+ const context_files_1 = require("./context-files");
78
79
  const guardrails_1 = require("./guardrails");
79
80
  const time_context_1 = require("./time-context");
80
81
  // Idle window after the LAST mid-turn event before we declare the turn
@@ -1676,7 +1677,15 @@ async function runCodex(req, cwd, installed) {
1676
1677
  // codex has no --append-system-prompt; we prepend it to the prompt as
1677
1678
  // a "system instruction" preamble so entities still influence the run.
1678
1679
  const codexSystem = (0, time_context_1.appendTimeContext)(req.system_prompt);
1679
- const finalPrompt = `System instructions:\n${codexSystem.trim()}\n\nUser:\n${req.prompt}`;
1680
+ // codex has no --append-system-prompt, but it reads AGENTS.md from the working
1681
+ // directory as project guidance — the native home for an entity's system
1682
+ // prompt, and it keeps 30K characters of persona out of the user turn. The
1683
+ // glued form stays as the fallback when the file cannot be written (a project
1684
+ // folder with its own AGENTS.md, a read-only disk).
1685
+ const agentsMd = (0, codex_spawn_1.writeCodexAgentsMd)(cwd, codexSystem);
1686
+ const finalPrompt = agentsMd
1687
+ ? req.prompt
1688
+ : `System instructions:\n${codexSystem.trim()}\n\nUser:\n${req.prompt}`;
1680
1689
  // Opportunistic reclaim right before we allocate a new scratch CODEX_HOME,
1681
1690
  // so a burst of runs frees space proactively rather than waiting for the
1682
1691
  // next periodic sweep. No-op below the high-water mark.
@@ -2081,12 +2090,7 @@ async function buildDriveManifest(installed) {
2081
2090
  lines.push(` - ${sub.name}/${f.name}`);
2082
2091
  }
2083
2092
  const body = lines.length ? lines.join('\n') : ' (empty — nothing saved yet)';
2084
- return [
2085
- '## Your +Ai Drive',
2086
- "You have a private +Ai Drive — file storage for working files and artifacts (documents, exports, data you produce) that survives across runs. This is for FILES, not memory: to remember facts, preferences, decisions, or what you learned, use your DocFlows memory (see below), never the Drive and never a local file. Files currently in your Drive:",
2087
- body,
2088
- "Use your +Ai Drive tools (list_files, read_file, write_file, make_folder, …) to read, add, and organise files.",
2089
- ].join('\n');
2093
+ return { body, count: lines.length };
2090
2094
  }
2091
2095
  catch {
2092
2096
  return null;
@@ -2194,6 +2198,15 @@ async function runRequest(req) {
2194
2198
  }
2195
2199
  catch { /* keep original prompt */ }
2196
2200
  }
2201
+ // Conversation-scoped context files (context/*.md) from the dispatcher: the
2202
+ // last 50 messages, the sender, the chat. Best-effort — the inline briefing
2203
+ // is complete without them. See context-files.ts.
2204
+ if (req.context_files) {
2205
+ const ctx = (0, context_files_1.writeContextFiles)(cwd, req.context_files);
2206
+ if (ctx.skipped.length > 0) {
2207
+ console.error(`[context-files] ${ctx.skipped.length} not written [reqId=${req.id}]: ${ctx.skipped.map(s => `${s.path} (${s.reason})`).join('; ')}`);
2208
+ }
2209
+ }
2197
2210
  // Resolve and install MCPs / skills / packages before spawn.
2198
2211
  const installSet = resolveInstallSet(req, resolved);
2199
2212
  const installed = await (0, install_1.install)(cwd, {
@@ -2218,8 +2231,18 @@ async function runRequest(req) {
2218
2231
  // it begins the run already aware of the files it has. All spawn paths read
2219
2232
  // req.system_prompt, so mutating it here reaches every agent variant.
2220
2233
  try {
2221
- const driveManifest = await buildDriveManifest(installed);
2222
- if (driveManifest) {
2234
+ const drive = await buildDriveManifest(installed);
2235
+ if (drive) {
2236
+ const intro = "You have a private +Ai Drive — file storage for working files and artifacts (documents, exports, data you produce) that survives across runs. This is for FILES, not memory: to remember facts, preferences, decisions, or what you learned, use your DocFlows memory (see below), never the Drive and never a local file.";
2237
+ const tools = 'Use your +Ai Drive tools (list_files, read_file, write_file, make_folder, …) to read, add, and organise files.';
2238
+ // The listing can run to hundreds of lines and changes whenever a file
2239
+ // does, so it lives in a file the entity reads when it needs one, and the
2240
+ // system prompt keeps a two-line pointer that stays stable turn to turn.
2241
+ // If the file cannot be written, the full listing goes inline as before.
2242
+ const wrote = (0, context_files_1.writeContextFiles)(cwd, { 'context/drive.md': `# Your +Ai Drive\n\nFiles currently in your Drive:\n${drive.body}\n` }).written.length > 0;
2243
+ const driveManifest = wrote
2244
+ ? ['## Your +Ai Drive', intro, `Your current file list (${drive.count} entries) is in ./context/drive.md — read it when you need a file.`, tools].join('\n')
2245
+ : ['## Your +Ai Drive', `${intro} Files currently in your Drive:`, drive.body, tools].join('\n');
2223
2246
  req.system_prompt =
2224
2247
  req.system_prompt && req.system_prompt.trim().length > 0
2225
2248
  ? `${req.system_prompt}\n\n${driveManifest}`
package/dist/tui/run.d.ts CHANGED
@@ -3,17 +3,8 @@ export declare function shouldRenderTui(env: {
3
3
  stdinTTY: boolean;
4
4
  noTui: boolean;
5
5
  }): boolean;
6
- /** Why the console gave the terminal back. Only `quit` is the user asking
7
- * to stop; the rest are the terminal being taken away underneath us. */
8
- export type LeaveReason = 'quit' | 'SIGHUP' | 'SIGTTIN' | 'SIGTTOU' | 'stdin-error';
9
- /** What giving the terminal back should cost. */
10
- export type LeaveAction =
11
- /** Close the screen and go — nothing of ours is running here. */
12
- 'exit'
13
- /** Drain the in-process daemon first, then go. */
14
- | 'shutdown'
15
- /** Close the screen and keep the daemon running headless. */
16
- | 'detach';
6
+ /** What the screen does when it is leaving. */
7
+ export type LeaveAction = 'exit' | 'shutdown' | 'detach';
17
8
  /**
18
9
  * The rule that decides whether this node survives the night.
19
10
  *
@@ -25,15 +16,15 @@ export type LeaveAction =
25
16
  *
26
17
  * Pure and exported so the rule is checkable without a TTY.
27
18
  */
28
- export declare function leaveAction(reason: LeaveReason, ownsDaemon: boolean): LeaveAction;
19
+ export declare function leaveAction(reason: string, ownsDaemon: boolean): LeaveAction;
29
20
  export declare function runDashboard(opts: {
30
21
  viewerMode: boolean;
31
22
  pid: number | null;
32
23
  startedAt: number | null;
33
24
  version: string;
34
- /** The in-process daemon's graceful shutdown, when this process owns one.
35
- * Its presence is also how the screen knows quitting means stopping the
36
- * node rather than closing a window onto someone else's. */
25
+ /** Present when the daemon runs in THIS process: how to stop it properly
26
+ * when the user actually asks — and how to tell that apart from the
27
+ * terminal merely disappearing. */
37
28
  stop?: () => Promise<void>;
38
29
  }): Promise<void>;
39
30
  /** `ainode harnesses` — straight to the harness screen. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.30.10",
3
+ "version": "0.30.12",
4
4
  "description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
5
5
  "license": "MIT",
6
6
  "keywords": [