@xdelivered/emberflow 0.2.0 → 0.4.0

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 (88) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +185 -14
  19. package/dist/server/runRegistry.d.ts +52 -1
  20. package/dist/server/runRegistry.js +170 -1
  21. package/dist/server/subflowRunner.d.ts +48 -1
  22. package/dist/server/subflowRunner.js +34 -7
  23. package/dist/server/updateCheck.d.ts +68 -0
  24. package/dist/server/updateCheck.js +142 -0
  25. package/dist/src/engine/executor.js +4 -3
  26. package/package.json +27 -6
  27. package/server/agentRoute.test.ts +20 -0
  28. package/server/agents/codexParse.test.ts +37 -0
  29. package/server/agents/codexParse.ts +34 -0
  30. package/server/agents/detect.test.ts +26 -7
  31. package/server/agents/detect.ts +82 -14
  32. package/server/agents/modelRejectionHint.ts +2 -1
  33. package/server/agents/prompt.test.ts +128 -0
  34. package/server/agents/prompt.ts +102 -3
  35. package/server/agents/runManager.test.ts +9 -0
  36. package/server/agents/runManager.ts +37 -7
  37. package/server/client.ts +10 -4
  38. package/server/index.ts +189 -13
  39. package/server/nodeRun.test.ts +189 -0
  40. package/server/runRegistry.ts +200 -3
  41. package/server/setupStatus.test.ts +3 -0
  42. package/server/stepDrill.test.ts +380 -0
  43. package/server/subflowRunner.ts +92 -11
  44. package/server/updateCheck.test.ts +209 -0
  45. package/server/updateCheck.ts +175 -0
  46. package/server/workflowTestRoute.test.ts +1 -1
  47. package/src/App.tsx +82 -2
  48. package/src/components/AgentConsole.tsx +7 -280
  49. package/src/components/AgentStream.test.tsx +88 -0
  50. package/src/components/AgentStream.tsx +303 -0
  51. package/src/components/CreateModal.tsx +43 -9
  52. package/src/components/Dock.tsx +1 -1
  53. package/src/components/EmptyState.test.tsx +49 -0
  54. package/src/components/EmptyState.tsx +61 -0
  55. package/src/components/EnvironmentDialog.tsx +4 -1
  56. package/src/components/EnvironmentPicker.tsx +8 -3
  57. package/src/components/EnvironmentsDialog.tsx +4 -1
  58. package/src/components/InfraPanel.tsx +30 -2
  59. package/src/components/InfrastructureDialog.test.tsx +97 -0
  60. package/src/components/InfrastructureDialog.tsx +111 -0
  61. package/src/components/RunbookView.tsx +70 -6
  62. package/src/components/SettingsDialog.tsx +4 -59
  63. package/src/components/Sidebar.tsx +6 -26
  64. package/src/components/StatusBar.tsx +227 -26
  65. package/src/components/Toolbar.tsx +28 -16
  66. package/src/components/UpdateChip.test.tsx +31 -0
  67. package/src/components/WelcomeDialog.test.tsx +384 -13
  68. package/src/components/WelcomeDialog.tsx +996 -177
  69. package/src/engine/executor.ts +4 -3
  70. package/src/lib/guidedQuestions.test.ts +224 -0
  71. package/src/lib/guidedQuestions.ts +181 -0
  72. package/src/lib/runbookModel.ts +3 -3
  73. package/src/lib/runbookProjection.test.ts +43 -0
  74. package/src/lib/runbookProjection.ts +26 -6
  75. package/src/store/agentClient.ts +27 -8
  76. package/src/store/apiTree.ts +12 -0
  77. package/src/store/builderStore.test.ts +441 -99
  78. package/src/store/builderStore.ts +383 -312
  79. package/src/store/serverRunner.ts +56 -5
  80. package/src/store/setupClient.ts +7 -2
  81. package/src/store/updateClient.ts +50 -0
  82. package/studio-dist/assets/index-CAIDjNhv.css +1 -0
  83. package/studio-dist/assets/index-CGwEx82J.js +65 -0
  84. package/studio-dist/index.html +2 -2
  85. package/templates/skills/emberflow-model-process/SKILL.md +82 -9
  86. package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
  87. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  88. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -12,6 +12,11 @@ export const SUBFLOW_DEPTH_CAP = 8;
12
12
  */
13
13
  export function makeSubflowRunner(opts, ancestry) {
14
14
  return async (workflowId, input, callerNodeId) => {
15
+ // A cancelled stepped run's unwinding parents (their runSubflow was
16
+ // rejected) may retry: never push a new child that nothing will step.
17
+ if (opts.drill?.cancelled) {
18
+ return { status: 'failed', error: 'run cancelled' };
19
+ }
15
20
  if (ancestry.length >= SUBFLOW_DEPTH_CAP) {
16
21
  return { status: 'failed', error: `subflow depth cap (${SUBFLOW_DEPTH_CAP}) exceeded` };
17
22
  }
@@ -40,17 +45,39 @@ export function makeSubflowRunner(opts, ancestry) {
40
45
  mocks: childFlow.mocks ?? {},
41
46
  trace: opts.trace,
42
47
  subflowRunner: makeSubflowRunner(opts, [...ancestry, workflowId]),
43
- events: opts.onLog
48
+ events: opts.onLog || (opts.drill && opts.onNodeState)
44
49
  ? {
45
- onLog: (line) => opts.onLog({
46
- ...line,
47
- nodeLabel: `${childFlow.name} ${line.nodeLabel ?? ''}`,
48
- nodeId: line.nodeId ? `${callerNodeId}/${line.nodeId}` : callerNodeId,
49
- }),
50
+ ...(opts.onLog
51
+ ? {
52
+ onLog: (line) => opts.onLog({
53
+ ...line,
54
+ nodeLabel: `${childFlow.name} › ${line.nodeLabel ?? ''}`,
55
+ nodeId: line.nodeId ? `${callerNodeId}/${line.nodeId}` : callerNodeId,
56
+ }),
57
+ }
58
+ : {}),
59
+ // Stepped children stream their node states into the ROOT
60
+ // run's SSE stream, tagged with the child flow's id.
61
+ ...(opts.drill && opts.onNodeState
62
+ ? {
63
+ onNodeStateChange: (nodeId, state) => opts.onNodeState(childFlow.id, nodeId, state),
64
+ }
65
+ : {}),
50
66
  }
51
67
  : undefined,
52
68
  });
53
- const childRun = await childHandle.runToEnd();
69
+ // Stepped run: don't run the child here. Push it onto the shared drill
70
+ // stack and block this Subflow node until the registry's composite
71
+ // step() drives the child to completion and pops the entry — resolving
72
+ // with the completed child run, which flows into the exact same
73
+ // output-extraction / failure-mapping path runToEnd feeds today.
74
+ const childRun = opts.drill
75
+ ? await new Promise((resolve, reject) => {
76
+ const entry = { handle: childHandle, workflowId, viaNodeId: callerNodeId, resolve, reject };
77
+ opts.drill.stack.push(entry);
78
+ opts.drill.onChildStarted?.(entry);
79
+ })
80
+ : await childHandle.runToEnd();
54
81
  if (childRun.status !== 'succeeded') {
55
82
  // Surface the child's actual failure (e.g. the Mock-mode "would touch
56
83
  // real infrastructure" boundary message) through the Subflow node's
@@ -0,0 +1,68 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Update notifier + installer plumbing for consumer projects (they install
4
+ * @xdelivered/emberflow from npm and run `npx emberflow dev`).
5
+ *
6
+ * - checkForUpdate: asks the npm registry for the latest published version and
7
+ * compares it against the running one. Fail-SILENT by design: any network,
8
+ * HTTP, or parse failure returns null — an update nudge must never break or
9
+ * slow the studio. Results (including failures) are cached in-memory for an
10
+ * hour so /update-status stays cheap and the registry isn't hammered.
11
+ * - runNpmInstall: the actual one-click updater — `npm install <pkg>@latest`
12
+ * spawned (no shell) in the project root. Injectable spawn for tests.
13
+ */
14
+ export declare const EMBERFLOW_PACKAGE = "@xdelivered/emberflow";
15
+ export interface UpdateCheckResult {
16
+ current: string;
17
+ latest: string;
18
+ updateAvailable: boolean;
19
+ }
20
+ export interface CheckForUpdateOpts {
21
+ /** Registry base URL. Defaults to EMBERFLOW_REGISTRY env, then npmjs. */
22
+ registry?: string;
23
+ /** Cache TTL in ms (default 1h). */
24
+ ttlMs?: number;
25
+ /** Injectable clock for TTL tests. */
26
+ now?: () => number;
27
+ /** Injectable fetch for tests. */
28
+ fetchFn?: typeof fetch;
29
+ }
30
+ /** Test hook: clear the in-memory check cache. */
31
+ export declare function resetUpdateCheckCache(): void;
32
+ /**
33
+ * Latest-version check against the npm registry. Returns null when the check
34
+ * is disabled (EMBERFLOW_UPDATE_CHECK=0) or unavailable for ANY reason —
35
+ * never throws.
36
+ */
37
+ export declare function checkForUpdate(currentVersion: string, opts?: CheckForUpdateOpts): Promise<UpdateCheckResult | null>;
38
+ /**
39
+ * The running package's own version, read from its package.json. Two layouts,
40
+ * same probe as prompt.ts's EMBERFLOW_BIN: in the source repo this file is
41
+ * server/updateCheck.ts and the package root is one level up; in the shipped
42
+ * package it runs from dist/server/updateCheck.js and the root is two up.
43
+ */
44
+ export declare function ownVersion(): string | null;
45
+ /**
46
+ * True when the consumer's node_modules/@xdelivered/emberflow is a symlink —
47
+ * a `file:`/`npm link` dev setup that an npm install would clobber. POST
48
+ * /update refuses in that case.
49
+ */
50
+ export declare function isLinkedInstall(projectRoot: string): boolean;
51
+ export type InstallResult = {
52
+ ok: true;
53
+ } | {
54
+ ok: false;
55
+ error: string;
56
+ };
57
+ export interface RunNpmInstallOpts {
58
+ /** Injectable spawner so tests never actually npm install. */
59
+ spawnFn?: typeof spawn;
60
+ /** Kill-and-fail deadline (default 5min). */
61
+ timeoutMs?: number;
62
+ }
63
+ /**
64
+ * Runs `npm install @xdelivered/emberflow@latest` in the project root via
65
+ * spawn (no shell). Captures the last ~4KB of combined output; on failure
66
+ * that tail is the error. Never rejects.
67
+ */
68
+ export declare function runNpmInstall(projectRoot: string, opts?: RunNpmInstallOpts): Promise<InstallResult>;
@@ -0,0 +1,142 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { newer } from './agents/detect.js';
6
+ /**
7
+ * Update notifier + installer plumbing for consumer projects (they install
8
+ * @xdelivered/emberflow from npm and run `npx emberflow dev`).
9
+ *
10
+ * - checkForUpdate: asks the npm registry for the latest published version and
11
+ * compares it against the running one. Fail-SILENT by design: any network,
12
+ * HTTP, or parse failure returns null — an update nudge must never break or
13
+ * slow the studio. Results (including failures) are cached in-memory for an
14
+ * hour so /update-status stays cheap and the registry isn't hammered.
15
+ * - runNpmInstall: the actual one-click updater — `npm install <pkg>@latest`
16
+ * spawned (no shell) in the project root. Injectable spawn for tests.
17
+ */
18
+ export const EMBERFLOW_PACKAGE = '@xdelivered/emberflow';
19
+ const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
20
+ const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h
21
+ const INSTALL_TIMEOUT_MS = 5 * 60 * 1000; // 5min
22
+ const OUTPUT_TAIL_BYTES = 4096;
23
+ // Single-entry cache: the runner only ever checks one package/version pair.
24
+ // Failures are cached too — a flaky network shouldn't retrigger a fetch on
25
+ // every /update-status poll.
26
+ let cache = null;
27
+ /** Test hook: clear the in-memory check cache. */
28
+ export function resetUpdateCheckCache() {
29
+ cache = null;
30
+ }
31
+ /**
32
+ * Latest-version check against the npm registry. Returns null when the check
33
+ * is disabled (EMBERFLOW_UPDATE_CHECK=0) or unavailable for ANY reason —
34
+ * never throws.
35
+ */
36
+ export async function checkForUpdate(currentVersion, opts = {}) {
37
+ if (process.env.EMBERFLOW_UPDATE_CHECK === '0')
38
+ return null;
39
+ const now = opts.now ?? Date.now;
40
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
41
+ if (cache && now() - cache.at < ttlMs)
42
+ return cache.result;
43
+ const registry = opts.registry ?? process.env.EMBERFLOW_REGISTRY ?? DEFAULT_REGISTRY;
44
+ const fetchFn = opts.fetchFn ?? fetch;
45
+ let result = null;
46
+ try {
47
+ const res = await fetchFn(`${registry}/${EMBERFLOW_PACKAGE}/latest`);
48
+ if (res.ok) {
49
+ const body = (await res.json());
50
+ if (typeof body.version === 'string' && body.version.length > 0) {
51
+ result = {
52
+ current: currentVersion,
53
+ latest: body.version,
54
+ updateAvailable: newer(body.version, currentVersion),
55
+ };
56
+ }
57
+ }
58
+ }
59
+ catch {
60
+ result = null; // fail-silent: network error, bad JSON, anything
61
+ }
62
+ cache = { at: now(), result };
63
+ return result;
64
+ }
65
+ /**
66
+ * The running package's own version, read from its package.json. Two layouts,
67
+ * same probe as prompt.ts's EMBERFLOW_BIN: in the source repo this file is
68
+ * server/updateCheck.ts and the package root is one level up; in the shipped
69
+ * package it runs from dist/server/updateCheck.js and the root is two up.
70
+ */
71
+ export function ownVersion() {
72
+ const here = dirname(fileURLToPath(import.meta.url));
73
+ for (const root of [resolve(here, '..'), resolve(here, '../..')]) {
74
+ const file = join(root, 'package.json');
75
+ if (!existsSync(file))
76
+ continue;
77
+ try {
78
+ const pkg = JSON.parse(readFileSync(file, 'utf8'));
79
+ if (typeof pkg.version === 'string')
80
+ return pkg.version;
81
+ }
82
+ catch {
83
+ // malformed — try the next candidate
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * True when the consumer's node_modules/@xdelivered/emberflow is a symlink —
90
+ * a `file:`/`npm link` dev setup that an npm install would clobber. POST
91
+ * /update refuses in that case.
92
+ */
93
+ export function isLinkedInstall(projectRoot) {
94
+ try {
95
+ return lstatSync(join(projectRoot, 'node_modules', '@xdelivered', 'emberflow')).isSymbolicLink();
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ /**
102
+ * Runs `npm install @xdelivered/emberflow@latest` in the project root via
103
+ * spawn (no shell). Captures the last ~4KB of combined output; on failure
104
+ * that tail is the error. Never rejects.
105
+ */
106
+ export function runNpmInstall(projectRoot, opts = {}) {
107
+ const spawnFn = opts.spawnFn ?? spawn;
108
+ const timeoutMs = opts.timeoutMs ?? INSTALL_TIMEOUT_MS;
109
+ return new Promise((resolveInstall) => {
110
+ let tail = '';
111
+ const append = (chunk) => {
112
+ tail = (tail + String(chunk)).slice(-OUTPUT_TAIL_BYTES);
113
+ };
114
+ let child;
115
+ try {
116
+ child = spawnFn('npm', ['install', `${EMBERFLOW_PACKAGE}@latest`], {
117
+ cwd: projectRoot,
118
+ stdio: ['ignore', 'pipe', 'pipe'],
119
+ });
120
+ }
121
+ catch (err) {
122
+ resolveInstall({ ok: false, error: err instanceof Error ? err.message : String(err) });
123
+ return;
124
+ }
125
+ child.stdout?.on('data', append);
126
+ child.stderr?.on('data', append);
127
+ let settled = false;
128
+ const settle = (result) => {
129
+ if (settled)
130
+ return;
131
+ settled = true;
132
+ clearTimeout(timer);
133
+ resolveInstall(result);
134
+ };
135
+ const timer = setTimeout(() => {
136
+ child.kill('SIGKILL');
137
+ settle({ ok: false, error: `npm install timed out after ${Math.round(timeoutMs / 1000)}s\n${tail}`.trim() });
138
+ }, timeoutMs);
139
+ child.on('error', (err) => settle({ ok: false, error: err.message }));
140
+ child.on('close', (code) => settle(code === 0 ? { ok: true } : { ok: false, error: tail.trim() || `npm install exited with code ${code}` }));
141
+ });
142
+ }
@@ -42,10 +42,11 @@ function envRefName(value) {
42
42
  function resolveEnvRef(name, vars) {
43
43
  if (!(name in vars)) {
44
44
  // The most common cause is not a missing key but a run with NO environment
45
- // at all: the in-tab engine has no vars, so flows seeded with $env refs
46
- // need the server runner. Say so, instead of a bare "missing".
45
+ // at all: an environment-less run (e.g. a scenario test) has no vars, so
46
+ // flows seeded with $env refs need a real environment. Say so, instead of a
47
+ // bare "missing".
47
48
  const hint = Object.keys(vars).length === 0
48
- ? ' — this run has no environment variables (in-tab/browser runs never do; make sure the runner is online and the engine is set to Server or Auto)'
49
+ ? ' — this run has no environment variables; run it against an environment that defines them'
49
50
  : '';
50
51
  throw new Error(`Missing environment variable: ${name}${hint}`);
51
52
  }
package/package.json CHANGED
@@ -1,16 +1,27 @@
1
1
  {
2
2
  "name": "@xdelivered/emberflow",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Visual builder and runner for API operations modelled as node graphs, with scenarios, mocks and agent skills.",
6
- "keywords": ["api", "workflow", "node-graph", "low-code", "visual-builder", "agent-skills"],
6
+ "keywords": [
7
+ "api",
8
+ "workflow",
9
+ "node-graph",
10
+ "low-code",
11
+ "visual-builder",
12
+ "agent-skills"
13
+ ],
7
14
  "license": "MIT",
8
- "publishConfig": { "access": "public" },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
9
18
  "repository": {
10
19
  "type": "git",
11
20
  "url": "https://github.com/pmccurley87/emberflow.git"
12
21
  },
13
- "bin": { "emberflow": "bin/emberflow.mjs" },
22
+ "bin": {
23
+ "emberflow": "bin/emberflow.mjs"
24
+ },
14
25
  "exports": {
15
26
  ".": {
16
27
  "types": "./dist/src/config.d.ts",
@@ -25,7 +36,15 @@
25
36
  "default": "./dist/server/environments.js"
26
37
  }
27
38
  },
28
- "files": ["bin", "server", "src", "dist", "studio-dist", "templates", "README.md"],
39
+ "files": [
40
+ "bin",
41
+ "server",
42
+ "src",
43
+ "dist",
44
+ "studio-dist",
45
+ "templates",
46
+ "README.md"
47
+ ],
29
48
  "scripts": {
30
49
  "dev": "vite",
31
50
  "build": "tsc -b && vite build",
@@ -73,7 +92,9 @@
73
92
  "tsx": "^4.22.5"
74
93
  },
75
94
  "peerDependenciesMeta": {
76
- "tsx": { "optional": true }
95
+ "tsx": {
96
+ "optional": true
97
+ }
77
98
  },
78
99
  "devDependencies": {
79
100
  "@types/express": "^5.0.6",
@@ -124,6 +124,26 @@ describe('POST /agent — setup-environments route validation', () => {
124
124
  });
125
125
  });
126
126
 
127
+ describe('POST /agent — guided-setup route validation', () => {
128
+ it('accepts a guided-setup intent WITHOUT flowId or environment (instruction-only, like setup-environments)', async () => {
129
+ const res = await postAgentWhenFree({ intent: { action: 'guided-setup', instruction: 'dev and prod please' } });
130
+ expect(res.status).toBe(201);
131
+ const body = await res.json();
132
+ expect(typeof body.agentRunId).toBe('string');
133
+ expect(body.agentRunId.length).toBeGreaterThan(0);
134
+ });
135
+
136
+ it('accepts a guided-setup intent with an empty instruction (the no-notes start)', async () => {
137
+ const res = await postAgentWhenFree({ intent: { action: 'guided-setup', instruction: '' } });
138
+ expect(res.status).toBe(201);
139
+ });
140
+
141
+ it('400s a guided-setup intent with no instruction field', async () => {
142
+ const res = await postAgent({ intent: { action: 'guided-setup' } });
143
+ expect(res.status).toBe(400);
144
+ });
145
+ });
146
+
127
147
  /** The run manager is single-flight per project (409 while a previous stub run
128
148
  * winds down) — retry briefly so route-validation asserts don't race it. */
129
149
  async function postAgentWhenFree(body: unknown): Promise<Response> {
@@ -55,4 +55,41 @@ describe('parseCodexLine', () => {
55
55
  expect(parseCodexLine('')).toBeNull();
56
56
  expect(parseCodexLine('not json')).toBeNull();
57
57
  });
58
+
59
+ // Captured live from codex 0.142.5 rejecting the default model: the failure
60
+ // arrives as a top-level `error` line + a terminal `turn.failed`, both with
61
+ // the human-readable cause double-encoded as JSON-in-a-string. Unhandled,
62
+ // the adapter could only report a generic "codex exited with code 1".
63
+ const REJECTION =
64
+ '{"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The \'gpt-5.6-sol\' model requires a newer version of Codex. Please upgrade to the latest app or CLI and try again."}}';
65
+
66
+ it('maps turn.failed to a terminal error with the inner message unwrapped and the stale-CLI hint', () => {
67
+ const line = JSON.stringify({ type: 'turn.failed', error: { message: REJECTION } });
68
+ const event = parseCodexLine(line);
69
+ expect(event?.type).toBe('error');
70
+ const text = (event as { text: string }).text;
71
+ expect(text).toContain("The 'gpt-5.6-sol' model requires a newer version of Codex");
72
+ expect(text).toContain('hint: your codex CLI may be too old');
73
+ });
74
+
75
+ it('maps a top-level error line to a non-terminal ⚠ message with the inner message unwrapped', () => {
76
+ const line = JSON.stringify({ type: 'error', message: REJECTION });
77
+ const event = parseCodexLine(line);
78
+ expect(event?.type).toBe('message');
79
+ expect((event as { text: string }).text).toContain(
80
+ "⚠ The 'gpt-5.6-sol' model requires a newer version of Codex",
81
+ );
82
+ });
83
+
84
+ it('turn.failed with a plain (non-JSON) message passes it through verbatim', () => {
85
+ const line = JSON.stringify({ type: 'turn.failed', error: { message: 'boom' } });
86
+ expect(parseCodexLine(line)).toEqual({ type: 'error', text: 'boom' });
87
+ });
88
+
89
+ it('turn.failed with no error payload still terminates with a generic message', () => {
90
+ expect(parseCodexLine('{"type":"turn.failed"}')).toEqual({
91
+ type: 'error',
92
+ text: 'codex turn failed',
93
+ });
94
+ });
58
95
  });
@@ -1,4 +1,5 @@
1
1
  import type { AgentEvent } from './types';
2
+ import { modelRejectionHint } from './modelRejectionHint';
2
3
 
3
4
  interface CodexCommandExecutionItem {
4
5
  type: 'command_execution';
@@ -55,6 +56,24 @@ interface CodexLine {
55
56
  type: string;
56
57
  item?: CodexItem;
57
58
  usage?: Record<string, number>;
59
+ message?: string;
60
+ error?: { message?: string };
61
+ }
62
+
63
+ /**
64
+ * Codex wraps API failures as JSON-in-a-string: `turn.failed`'s error.message
65
+ * (and top-level `error`'s message) is often itself a serialized
66
+ * `{"type":"error","status":400,"error":{"message":"…"}}` payload. Dig out the
67
+ * innermost human-readable message, falling back to the raw string.
68
+ */
69
+ function extractCodexErrorMessage(raw: string | undefined): string {
70
+ if (!raw) return 'codex turn failed';
71
+ try {
72
+ const inner = JSON.parse(raw) as { error?: { message?: string }; message?: string };
73
+ return inner.error?.message ?? inner.message ?? raw;
74
+ } catch {
75
+ return raw;
76
+ }
58
77
  }
59
78
 
60
79
  export function parseCodexLine(line: string): AgentEvent | null {
@@ -120,6 +139,21 @@ export function parseCodexLine(line: string): AgentEvent | null {
120
139
  case 'turn.completed':
121
140
  return { type: 'done', usage: parsed.usage };
122
141
 
142
+ // The turn itself failed (e.g. the API rejected the request) — this IS the
143
+ // run failing, unlike an `error` ITEM above. Codex exits 1 after emitting
144
+ // it; without handling it here the adapter can only synthesize a generic
145
+ // "codex exited with code 1" and the real cause stays buried.
146
+ case 'turn.failed': {
147
+ const text = extractCodexErrorMessage(parsed.error?.message);
148
+ const hint = modelRejectionHint('codex', text);
149
+ return { type: 'error', text: hint ? `${text} (${hint})` : text };
150
+ }
151
+
152
+ // Top-level `error` lines precede turn.failed with the same payload —
153
+ // surface as a visible diagnostic, let turn.failed be the terminal event.
154
+ case 'error':
155
+ return { type: 'message', text: `⚠ ${extractCodexErrorMessage(parsed.message)}` };
156
+
123
157
  default:
124
158
  return null;
125
159
  }
@@ -7,19 +7,19 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
7
  const fixture = (name: string) => path.join(__dirname, '__fixtures__', name);
8
8
 
9
9
  describe('detectAgents', () => {
10
- it('returns only the CLIs the injected probe reports as present, with parsed versions', () => {
10
+ it('returns only the CLIs the injected probe reports as present, with parsed versions and the bin', () => {
11
11
  expect(detectAgents((bin) => (bin === 'codex' ? { version: '0.142.5' } : undefined))).toEqual([
12
- { kind: 'codex', version: '0.142.5' },
12
+ { kind: 'codex', version: '0.142.5', bin: 'codex' },
13
13
  ]);
14
14
  expect(detectAgents((bin) => (bin === 'claude' ? { version: '1.2.3' } : undefined))).toEqual([
15
- { kind: 'claude', version: '1.2.3' },
15
+ { kind: 'claude', version: '1.2.3', bin: 'claude' },
16
16
  ]);
17
17
  });
18
18
 
19
19
  it('returns both when both are present', () => {
20
- expect(detectAgents(() => ({ version: '1.0.0' }))).toEqual([
21
- { kind: 'codex', version: '1.0.0' },
22
- { kind: 'claude', version: '1.0.0' },
20
+ expect(detectAgents((bin) => (bin === 'codex' || bin === 'claude' ? { version: '1.0.0' } : undefined))).toEqual([
21
+ { kind: 'codex', version: '1.0.0', bin: 'codex' },
22
+ { kind: 'claude', version: '1.0.0', bin: 'claude' },
23
23
  ]);
24
24
  });
25
25
 
@@ -29,10 +29,29 @@ describe('detectAgents', () => {
29
29
 
30
30
  it('reports null version when present but the output has no parseable semver token', () => {
31
31
  expect(detectAgents((bin) => (bin === 'codex' ? { version: null } : undefined))).toEqual([
32
- { kind: 'codex', version: null },
32
+ { kind: 'codex', version: null, bin: 'codex' },
33
33
  ]);
34
34
  });
35
35
 
36
+ it('picks the NEWEST version across candidate locations, not the PATH shim', () => {
37
+ // PATH `codex` is pinned old; the ChatGPT.app bundle is newer — the bundle wins.
38
+ const versions: Record<string, string> = {
39
+ codex: '0.142.5',
40
+ '/Applications/ChatGPT.app/Contents/Resources/codex': '0.156.0',
41
+ };
42
+ const found = detectAgents((bin) => (versions[bin] ? { version: versions[bin] } : undefined));
43
+ expect(found).toEqual([
44
+ { kind: 'codex', version: '0.156.0', bin: '/Applications/ChatGPT.app/Contents/Resources/codex' },
45
+ ]);
46
+ });
47
+
48
+ it('a parseable version beats a null one regardless of probe order', () => {
49
+ const found = detectAgents((bin) =>
50
+ bin === 'codex' ? { version: null } : bin === '/opt/homebrew/bin/codex' ? { version: '0.1.0' } : undefined,
51
+ );
52
+ expect(found).toEqual([{ kind: 'codex', version: '0.1.0', bin: '/opt/homebrew/bin/codex' }]);
53
+ });
54
+
36
55
  it('defaults to a real PATH probe that returns an array', () => {
37
56
  expect(Array.isArray(detectAgents())).toBe(true);
38
57
  });
@@ -1,20 +1,40 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
  import type { AgentKind } from './types';
3
5
 
4
- const CANDIDATES: AgentKind[] = ['codex', 'claude'];
5
-
6
6
  const VERSION_RE = /\d+\.\d+\.\d+/;
7
7
 
8
+ /**
9
+ * Where each agent CLI may live, beyond whatever PATH resolves. PATH shims can
10
+ * pin an OLD version (e.g. a superset-managed ~/.superset/bin/codex frozen at
11
+ * a release the API no longer accepts) while a newer binary sits elsewhere —
12
+ * so we probe every known location and pick the NEWEST, never just the first.
13
+ */
14
+ const CANDIDATE_BINS: Record<AgentKind, string[]> = {
15
+ codex: [
16
+ 'codex', // PATH resolution (may be a pinned shim)
17
+ join(homedir(), '.local', 'bin', 'codex'),
18
+ '/opt/homebrew/bin/codex',
19
+ '/usr/local/bin/codex',
20
+ // The ChatGPT desktop app bundles its own codex, updated with the app.
21
+ '/Applications/ChatGPT.app/Contents/Resources/codex',
22
+ ],
23
+ claude: ['claude', join(homedir(), '.local', 'bin', 'claude'), '/opt/homebrew/bin/claude', '/usr/local/bin/claude'],
24
+ };
25
+
8
26
  export interface DetectedAgent {
9
27
  kind: AgentKind;
10
28
  version: string | null;
29
+ /** The concrete binary the newest version was found at — what spawns should use. */
30
+ bin: string;
11
31
  }
12
32
 
13
33
  /**
14
- * Real PATH probe: runs `<bin> --version` and parses the first semver-ish
15
- * token out of stdout. Returns `undefined` when the binary isn't present/
16
- * runnable (spawn error, timeout, or nonzero exit); `null` version when it
17
- * ran but produced no parseable version string.
34
+ * Real probe: runs `<bin> --version` and parses the first semver-ish token out
35
+ * of stdout. Returns `undefined` when the binary isn't present/runnable (spawn
36
+ * error, timeout, or nonzero exit); `null` version when it ran but produced no
37
+ * parseable version string.
18
38
  */
19
39
  export function probe(bin: string): { version: string | null } | undefined {
20
40
  const result = spawnSync(bin, ['--version'], {
@@ -27,17 +47,65 @@ export function probe(bin: string): { version: string | null } | undefined {
27
47
  return { version: match ? match[0] : null };
28
48
  }
29
49
 
50
+ /** Numeric segment compare; a parseable version always beats null.
51
+ * Shared: agent-CLI detection here, and the package update check
52
+ * (server/updateCheck.ts) both rank semver-ish strings with it. */
53
+ export function newer(a: string | null, b: string | null): boolean {
54
+ if (a === null) return false;
55
+ if (b === null) return true;
56
+ const as = a.split('.').map(Number);
57
+ const bs = b.split('.').map(Number);
58
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
59
+ const d = (as[i] ?? 0) - (bs[i] ?? 0);
60
+ if (d !== 0) return d > 0;
61
+ }
62
+ return false;
63
+ }
64
+
30
65
  /**
31
- * Detects which coding-agent CLIs are available on PATH, and the version
32
- * each reports (null when present but unparseable). `probeBin` is
33
- * injectable so tests can stub PATH presence/version without shelling out
34
- * for real binaries.
66
+ * Detects which coding-agent CLIs are available probing PATH plus the known
67
+ * install locations for each kind and keeps the NEWEST version found per
68
+ * kind, with the binary path it came from. `probeBin` is injectable so tests
69
+ * can stub presence/version per location without shelling out.
35
70
  */
36
- export function detectAgents(probeBin: (bin: string) => { version: string | null } | undefined = probe): DetectedAgent[] {
71
+ export function detectAgents(
72
+ probeBin: (bin: string) => { version: string | null } | undefined = probe,
73
+ ): DetectedAgent[] {
37
74
  const found: DetectedAgent[] = [];
38
- for (const kind of CANDIDATES) {
39
- const result = probeBin(kind);
40
- if (result) found.push({ kind, version: result.version });
75
+ for (const kind of Object.keys(CANDIDATE_BINS) as AgentKind[]) {
76
+ let best: DetectedAgent | undefined;
77
+ const seen = new Set<string>();
78
+ for (const bin of CANDIDATE_BINS[kind]) {
79
+ if (seen.has(bin)) continue;
80
+ seen.add(bin);
81
+ const result = probeBin(bin);
82
+ if (!result) continue;
83
+ if (!best || newer(result.version, best.version)) {
84
+ best = { kind, version: result.version, bin };
85
+ }
86
+ }
87
+ if (best) found.push(best);
41
88
  }
42
89
  return found;
43
90
  }
91
+
92
+ // Probing every candidate spawns several subprocesses; /setup-status and
93
+ // /agent/available are polled by the studio, so cache briefly.
94
+ const RESOLVE_TTL_MS = 30_000;
95
+ let cachedAt = 0;
96
+ let cached: DetectedAgent[] | null = null;
97
+
98
+ /** Cached detection — the newest binary per kind, refreshed every 30s. */
99
+ export function detectAgentsCached(): DetectedAgent[] {
100
+ const now = Date.now();
101
+ if (!cached || now - cachedAt > RESOLVE_TTL_MS) {
102
+ cached = detectAgents();
103
+ cachedAt = now;
104
+ }
105
+ return cached;
106
+ }
107
+
108
+ /** The concrete binary path spawns should use for `kind` — the newest found. */
109
+ export function resolveAgentBin(kind: AgentKind): string | undefined {
110
+ return detectAgentsCached().find((a) => a.kind === kind)?.bin;
111
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AgentKind } from './types';
2
2
 
3
- const MODEL_REJECTION_RE = /unknown model|unsupported model|invalid model|model.*not.*(found|supported)/i;
3
+ const MODEL_REJECTION_RE =
4
+ /unknown model|unsupported model|invalid model|model.*not.*(found|supported)|model requires a newer version/i;
4
5
 
5
6
  /**
6
7
  * When an agent CLI exits nonzero without ever emitting a terminal `done`,