@mrpatronz/nexusflow 2.0.0 → 2.2.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 (112) hide show
  1. package/README.md +5 -1
  2. package/dist/agent/AcpCliAdapter.d.ts +75 -0
  3. package/dist/agent/AcpCliAdapter.d.ts.map +1 -0
  4. package/dist/agent/AcpCliAdapter.js +292 -0
  5. package/dist/agent/AcpCliAdapter.js.map +1 -0
  6. package/dist/agent/AcpCliAdapter.test.d.ts +2 -0
  7. package/dist/agent/AcpCliAdapter.test.d.ts.map +1 -0
  8. package/dist/agent/AcpCliAdapter.test.js +235 -0
  9. package/dist/agent/AcpCliAdapter.test.js.map +1 -0
  10. package/dist/agent/ClaudeCliAdapter.d.ts +40 -5
  11. package/dist/agent/ClaudeCliAdapter.d.ts.map +1 -1
  12. package/dist/agent/ClaudeCliAdapter.js +212 -15
  13. package/dist/agent/ClaudeCliAdapter.js.map +1 -1
  14. package/dist/agent/ClaudeCliAdapter.test.d.ts +2 -0
  15. package/dist/agent/ClaudeCliAdapter.test.d.ts.map +1 -0
  16. package/dist/agent/ClaudeCliAdapter.test.js +320 -0
  17. package/dist/agent/ClaudeCliAdapter.test.js.map +1 -0
  18. package/dist/agent/CliAdapterBase.d.ts +29 -12
  19. package/dist/agent/CliAdapterBase.d.ts.map +1 -1
  20. package/dist/agent/CliAdapterBase.js +76 -36
  21. package/dist/agent/CliAdapterBase.js.map +1 -1
  22. package/dist/agent/CodexCliAdapter.d.ts +51 -0
  23. package/dist/agent/CodexCliAdapter.d.ts.map +1 -0
  24. package/dist/agent/CodexCliAdapter.js +226 -0
  25. package/dist/agent/CodexCliAdapter.js.map +1 -0
  26. package/dist/agent/CodexCliAdapter.test.d.ts +2 -0
  27. package/dist/agent/CodexCliAdapter.test.d.ts.map +1 -0
  28. package/dist/agent/CodexCliAdapter.test.js +316 -0
  29. package/dist/agent/CodexCliAdapter.test.js.map +1 -0
  30. package/dist/agent/CopilotAcpAdapter.d.ts +7 -0
  31. package/dist/agent/CopilotAcpAdapter.d.ts.map +1 -0
  32. package/dist/agent/CopilotAcpAdapter.js +29 -0
  33. package/dist/agent/CopilotAcpAdapter.js.map +1 -0
  34. package/dist/agent/ProviderRegistry.d.ts +39 -3
  35. package/dist/agent/ProviderRegistry.d.ts.map +1 -1
  36. package/dist/agent/ProviderRegistry.js +43 -8
  37. package/dist/agent/ProviderRegistry.js.map +1 -1
  38. package/dist/agent/ProviderRegistry.test.js +52 -0
  39. package/dist/agent/ProviderRegistry.test.js.map +1 -1
  40. package/dist/agent/adapters.d.ts +7 -5
  41. package/dist/agent/adapters.d.ts.map +1 -1
  42. package/dist/agent/adapters.js +92 -17
  43. package/dist/agent/adapters.js.map +1 -1
  44. package/dist/agent/adapters.test.js +22 -0
  45. package/dist/agent/adapters.test.js.map +1 -1
  46. package/dist/agent/cliAvailability.d.ts +51 -13
  47. package/dist/agent/cliAvailability.d.ts.map +1 -1
  48. package/dist/agent/cliAvailability.js +163 -44
  49. package/dist/agent/cliAvailability.js.map +1 -1
  50. package/dist/agent/cliAvailability.test.js +140 -53
  51. package/dist/agent/cliAvailability.test.js.map +1 -1
  52. package/dist/agent/session.d.ts +2 -1
  53. package/dist/agent/session.d.ts.map +1 -1
  54. package/dist/agent/session.js +15 -4
  55. package/dist/agent/session.js.map +1 -1
  56. package/dist/agent/session.test.js +29 -8
  57. package/dist/agent/session.test.js.map +1 -1
  58. package/dist/core/workspace.d.ts +6 -0
  59. package/dist/core/workspace.d.ts.map +1 -1
  60. package/dist/core/workspace.js +17 -7
  61. package/dist/core/workspace.js.map +1 -1
  62. package/dist/core/workspace.test.js +12 -1
  63. package/dist/core/workspace.test.js.map +1 -1
  64. package/dist/gui/assets/DashboardPage-CUspfCap.js +1 -0
  65. package/dist/gui/assets/GettingStartedPage-BqtgARZW.js +1 -0
  66. package/dist/gui/assets/ProjectsPage-UBoQxI2G.js +1 -0
  67. package/dist/gui/assets/{ScaffoldRepoInline-D-2ZeuDK.js → ScaffoldRepoInline-DqoourJe.js} +1 -1
  68. package/dist/gui/assets/SettingsPage-BZl24tqD.js +1 -0
  69. package/dist/gui/assets/StartWorkPage-BGNFdBfj.js +1 -0
  70. package/dist/gui/assets/{StrategiesPage-Cizm0GWk.js → StrategiesPage-D0Y6A4Tw.js} +2 -2
  71. package/dist/gui/assets/WorkspaceLauncher-Cxv0O_wt.js +1 -0
  72. package/dist/gui/assets/WorkspacesPage-Cvb1rOeh.js +6 -0
  73. package/dist/gui/assets/index-CllPiiFL.js +52 -0
  74. package/dist/gui/assets/index-DGCBhMAn.css +2 -0
  75. package/dist/gui/index.html +2 -2
  76. package/dist/server.d.ts +13 -0
  77. package/dist/server.d.ts.map +1 -1
  78. package/dist/server.js +267 -39
  79. package/dist/server.js.map +1 -1
  80. package/dist/server.test.js +324 -18
  81. package/dist/server.test.js.map +1 -1
  82. package/dist/types.d.ts +13 -0
  83. package/dist/types.d.ts.map +1 -1
  84. package/dist/utils/detect-editors.d.ts.map +1 -1
  85. package/dist/utils/detect-editors.js +6 -0
  86. package/dist/utils/detect-editors.js.map +1 -1
  87. package/dist/utils/detect-editors.test.js +12 -0
  88. package/dist/utils/detect-editors.test.js.map +1 -1
  89. package/dist/utils/session-finder.d.ts +14 -0
  90. package/dist/utils/session-finder.d.ts.map +1 -1
  91. package/dist/utils/session-finder.js +127 -10
  92. package/dist/utils/session-finder.js.map +1 -1
  93. package/dist/utils/session-finder.test.js +104 -2
  94. package/dist/utils/session-finder.test.js.map +1 -1
  95. package/dist/utils/workspace-launch.d.ts +37 -0
  96. package/dist/utils/workspace-launch.d.ts.map +1 -0
  97. package/dist/utils/workspace-launch.js +200 -0
  98. package/dist/utils/workspace-launch.js.map +1 -0
  99. package/dist/utils/workspace-launch.test.d.ts +2 -0
  100. package/dist/utils/workspace-launch.test.d.ts.map +1 -0
  101. package/dist/utils/workspace-launch.test.js +94 -0
  102. package/dist/utils/workspace-launch.test.js.map +1 -0
  103. package/package.json +5 -3
  104. package/dist/gui/assets/DashboardPage-HNC3-OP0.js +0 -1
  105. package/dist/gui/assets/GettingStartedPage-Dguoya_Z.js +0 -1
  106. package/dist/gui/assets/ProjectsPage-DVR4j4sw.js +0 -1
  107. package/dist/gui/assets/SettingsPage-Bauo77q3.js +0 -1
  108. package/dist/gui/assets/StartWorkPage-DvuFEa5Y.js +0 -1
  109. package/dist/gui/assets/WorkspacesPage-DYOVfZqy.js +0 -6
  110. package/dist/gui/assets/external-link-BtYhMxno.js +0 -1
  111. package/dist/gui/assets/index-BqeoAIB1.js +0 -52
  112. package/dist/gui/assets/index-CsA2SueF.css +0 -2
package/dist/server.js CHANGED
@@ -23,13 +23,14 @@ import { createNewRepo } from './core/new-repo.js';
23
23
  import { loadProjects, createProject, updateProject, removeProject, slugifyProjectName } from './core/projects.js';
24
24
  import { getSessionCwd, isInPlace, resolveFeatureRepoPath } from './utils/feature.js';
25
25
  import { listBranches } from './utils/git.js';
26
- import { createWorkspace, listWorkspaces, loadFeatureConfig, deleteWorkspace, addRepoToWorkspace } from './core/workspace.js';
26
+ import { createWorkspace, listWorkspaces, loadFeatureConfig, loadWorkspaceManifest, deleteWorkspace, addRepoToWorkspace } from './core/workspace.js';
27
27
  import { loadWorkspaceState } from './core/workspace-state.js';
28
28
  import { analyzeAllRepos } from './analyzers/index.js';
29
29
  import { generateContextFiles } from './generators/index.js';
30
30
  import { detectAIAssistants } from './utils/detect-ai.js';
31
31
  import { detectEditors } from './utils/detect-editors.js';
32
- import { findSessions, getSessionTranscript } from './utils/session-finder.js';
32
+ import { buildWorkspaceLaunchPrompt, detectWorkspaceLaunchTargets, launchTargetIdForEditorCommand, launchWorkspaceTarget, } from './utils/workspace-launch.js';
33
+ import { canOpenCodexSessionInWorkspace, findSessions, getSessionTranscript } from './utils/session-finder.js';
33
34
  import { ProviderRegistry } from './agent/adapters.js';
34
35
  import { isValidSessionUuid } from './agent/session.js';
35
36
  import { getRepoStatus } from './utils/multi-git.js';
@@ -51,9 +52,45 @@ const __dirname = path.dirname(__filename);
51
52
  const guiPath = path.join(__dirname, 'gui');
52
53
  export const app = new Hono();
53
54
  const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
54
- app.get('/api/adapters/status', (c) => {
55
- return c.json(ProviderRegistry.getAllStatus());
56
- });
55
+ function hasTrustedLocalOrigin(origin) {
56
+ if (!origin)
57
+ return false;
58
+ try {
59
+ const { hostname } = new URL(origin);
60
+ return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ /** Validate and dispatch one renderer turn without letting malformed authority reach a harness. */
67
+ export function dispatchAgentInput(agent, provider, payload) {
68
+ if (typeof payload.input !== 'string' || !payload.input.trim()) {
69
+ return 'A non-empty input message is required.';
70
+ }
71
+ const executionProfile = ProviderRegistry.resolveExecutionProfile(provider, payload.executionProfile);
72
+ if (executionProfile === null) {
73
+ return 'Select a supported execution profile before sending this turn.';
74
+ }
75
+ void agent.send(payload.input, executionProfile);
76
+ return null;
77
+ }
78
+ /** Per-socket admission guard so a second prompt is rejected, never dropped. */
79
+ export class AgentTurnGate {
80
+ active = false;
81
+ tryBegin() {
82
+ if (this.active)
83
+ return false;
84
+ this.active = true;
85
+ return true;
86
+ }
87
+ settle() {
88
+ this.active = false;
89
+ }
90
+ isActive() {
91
+ return this.active;
92
+ }
93
+ }
57
94
  app.get('/ws', async (c, next) => {
58
95
  // Prevent Cross-Site WebSocket Hijacking (CSWSH)
59
96
  const origin = c.req.header('origin');
@@ -69,11 +106,16 @@ app.get('/ws', async (c, next) => {
69
106
  }
70
107
  }
71
108
  // Chat protocol:
72
- // client -> server: {type:'start', command, cwd, sessionId?, resume?} | {type:'input', input} | {type:'stop'} | 'ping'
73
- // server -> client: {type:'stream', text} | {type:'status', state:'busy'|'idle'} | {type:'system', message}
74
- // | {type:'error', message} | {type:'close', code} | {type:'pong'}
109
+ // client -> server: {type:'start', command, cwd, sessionId?, resume?}
110
+ // | {type:'input', input, turnId?, executionProfile?} | {type:'stop'} | 'ping'
111
+ // server -> client: {type:'stream', text} | {type:'session', id} | {type:'status', state:'busy'|'idle'} | {type:'system', message}
112
+ // | {type:'accepted', turnId?} | {type:'error', message}
113
+ // | {type:'rejected', reason:'busy', message, turnId?}
114
+ // | {type:'close', code} | {type:'pong'}
75
115
  return upgradeWebSocket((c) => {
76
116
  let agent = null;
117
+ let activeProvider = null;
118
+ const turnGate = new AgentTurnGate();
77
119
  return {
78
120
  onMessage(event, ws) {
79
121
  if (typeof event.data === 'string') {
@@ -87,8 +129,12 @@ app.get('/ws', async (c, next) => {
87
129
  ws.send(JSON.stringify({ type: 'pong' }));
88
130
  }
89
131
  else if (payload.type === 'start') {
132
+ turnGate.settle();
90
133
  if (agent) {
91
- agent.stop();
134
+ const previousAgent = agent;
135
+ agent = null;
136
+ activeProvider = null;
137
+ previousAgent.stop();
92
138
  }
93
139
  const provider = ProviderRegistry.getProvider(payload.command);
94
140
  if (!provider) {
@@ -103,35 +149,86 @@ app.get('/ws', async (c, next) => {
103
149
  }
104
150
  session = { id: payload.sessionId, resume: Boolean(payload.resume) };
105
151
  }
106
- agent = provider.createInstance();
107
- agent.on('data', (text) => {
152
+ const startedAgent = provider.createInstance();
153
+ agent = startedAgent;
154
+ activeProvider = provider;
155
+ const isCurrentAgent = () => agent === startedAgent;
156
+ startedAgent.on('data', (text) => {
157
+ if (!isCurrentAgent())
158
+ return;
108
159
  ws.send(JSON.stringify({ type: 'stream', text }));
109
160
  });
110
- agent.on('system', (message) => {
161
+ startedAgent.on('system', (message) => {
162
+ if (!isCurrentAgent())
163
+ return;
111
164
  ws.send(JSON.stringify({ type: 'system', message }));
112
165
  });
113
- agent.on('idle', () => {
166
+ startedAgent.on('session', (id) => {
167
+ if (!isCurrentAgent())
168
+ return;
169
+ // Provider output is still boundary input. Only inert UUIDs
170
+ // may be persisted by the renderer and sent back in argv.
171
+ if (isValidSessionUuid(id)) {
172
+ ws.send(JSON.stringify({ type: 'session', id }));
173
+ }
174
+ });
175
+ startedAgent.on('idle', () => {
176
+ if (!isCurrentAgent())
177
+ return;
178
+ turnGate.settle();
114
179
  ws.send(JSON.stringify({ type: 'status', state: 'idle' }));
115
180
  });
116
- agent.on('close', (code) => {
181
+ startedAgent.on('close', (code) => {
182
+ if (!isCurrentAgent())
183
+ return;
184
+ turnGate.settle();
117
185
  ws.send(JSON.stringify({ type: 'close', code }));
118
186
  });
119
- agent.on('error', (error) => {
187
+ startedAgent.on('error', (error) => {
188
+ if (!isCurrentAgent())
189
+ return;
120
190
  ws.send(JSON.stringify({ type: 'error', message: error?.message ?? String(error) }));
121
- ws.send(JSON.stringify({ type: 'status', state: 'idle' }));
122
191
  });
123
- agent.start(payload.cwd, session);
192
+ startedAgent.start(payload.cwd, session);
124
193
  }
125
194
  else if (payload.type === 'input') {
126
- if (agent) {
127
- agent.send(payload.input);
128
- ws.send(JSON.stringify({ type: 'status', state: 'busy' }));
195
+ if (agent && activeProvider) {
196
+ if (!turnGate.tryBegin()) {
197
+ const turnId = isValidSessionUuid(payload.turnId) ? payload.turnId : undefined;
198
+ ws.send(JSON.stringify({
199
+ type: 'rejected',
200
+ reason: 'busy',
201
+ message: 'The agent is still processing the current turn.',
202
+ ...(turnId ? { turnId } : {}),
203
+ }));
204
+ return;
205
+ }
206
+ const error = dispatchAgentInput(agent, activeProvider, payload);
207
+ if (error) {
208
+ turnGate.settle();
209
+ ws.send(JSON.stringify({ type: 'error', message: error }));
210
+ return;
211
+ }
212
+ // Some harness validation failures emit error + idle
213
+ // synchronously from send(); do not overwrite that settled
214
+ // state with a late busy frame.
215
+ if (turnGate.isActive()) {
216
+ const turnId = isValidSessionUuid(payload.turnId) ? payload.turnId : undefined;
217
+ ws.send(JSON.stringify({
218
+ type: 'accepted',
219
+ ...(turnId ? { turnId } : {}),
220
+ }));
221
+ ws.send(JSON.stringify({ type: 'status', state: 'busy' }));
222
+ }
129
223
  }
130
224
  }
131
225
  else if (payload.type === 'stop') {
226
+ turnGate.settle();
132
227
  if (agent) {
133
- agent.stop();
228
+ const stoppedAgent = agent;
134
229
  agent = null;
230
+ activeProvider = null;
231
+ stoppedAgent.stop();
135
232
  }
136
233
  }
137
234
  }
@@ -145,9 +242,12 @@ app.get('/ws', async (c, next) => {
145
242
  },
146
243
  onClose(_event, _ws) {
147
244
  console.log('[WS] Client disconnected');
245
+ turnGate.settle();
148
246
  if (agent) {
149
- agent.stop();
247
+ const disconnectedAgent = agent;
150
248
  agent = null;
249
+ activeProvider = null;
250
+ disconnectedAgent.stop();
151
251
  }
152
252
  },
153
253
  };
@@ -182,6 +282,31 @@ function assertWithin(baseDir, target) {
182
282
  export function resolveWorkspacePath(workspacesDir, id) {
183
283
  return assertWithin(workspacesDir, path.join(workspacesDir, id));
184
284
  }
285
+ /**
286
+ * Resolve a launch path through the filesystem and require it to be the exact
287
+ * workspace declared by its manifest. This prevents symlink escapes and stops
288
+ * loadFeatureConfig's parent-directory fallback from authorizing a child path.
289
+ */
290
+ async function resolveExactLaunchWorkspace(workspacesDir, candidatePath) {
291
+ const lexicalPath = assertWithin(workspacesDir, candidatePath);
292
+ const [canonicalRoot, canonicalWorkspace] = await Promise.all([
293
+ fs.realpath(workspacesDir),
294
+ fs.realpath(lexicalPath),
295
+ ]);
296
+ const safeWorkspacePath = assertWithin(canonicalRoot, canonicalWorkspace);
297
+ const feature = await loadWorkspaceManifest(safeWorkspacePath);
298
+ if (!feature || typeof feature.workspacePath !== 'string')
299
+ return null;
300
+ try {
301
+ const declaredWorkspace = await fs.realpath(path.resolve(feature.workspacePath));
302
+ return path.resolve(declaredWorkspace) === path.resolve(safeWorkspacePath)
303
+ ? safeWorkspacePath
304
+ : null;
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ }
185
310
  /** Safe sub-repo path for a repo name, contained within the workspace. */
186
311
  export function resolveRepoPath(workspacePath, repoName) {
187
312
  return assertWithin(workspacePath, path.join(workspacePath, repoName));
@@ -236,6 +361,29 @@ app.use('/api/*', cors({
236
361
  },
237
362
  }));
238
363
  // ─── API Endpoints ────────────────────────────────────────────────────────
364
+ // Status checks may execute a trusted local CLI from PATH. Keep that work
365
+ // behind a non-simple, same-machine POST so a remote page cannot trigger it
366
+ // with an image, link, or cross-origin fetch. These routes intentionally sit
367
+ // after the API CORS middleware so the separate Vite dev origin can read them.
368
+ app.get('/api/adapters/status', (c) => {
369
+ return c.json({ error: 'Use the same-origin POST status endpoint.' }, 405);
370
+ });
371
+ app.post('/api/adapters/status', (c) => {
372
+ if (!hasTrustedLocalOrigin(c.req.header('origin'))) {
373
+ return c.json({ error: 'A local browser origin is required.' }, 403);
374
+ }
375
+ return c.json(ProviderRegistry.getAllStatus());
376
+ });
377
+ app.post('/api/adapters/status/refresh', async (c) => {
378
+ if (!hasTrustedLocalOrigin(c.req.header('origin'))) {
379
+ return c.json({ error: 'A local browser origin is required.' }, 403);
380
+ }
381
+ const body = await c.req.json().catch(() => null);
382
+ if (body?.providerId !== 'claude-cli' && body?.providerId !== 'codex-cli') {
383
+ return c.json({ error: 'Only Claude Code and Codex status can be refreshed.' }, 400);
384
+ }
385
+ return c.json(ProviderRegistry.getAllStatus({ refreshProviderId: body.providerId }));
386
+ });
239
387
  // 1. Get current configuration
240
388
  app.get('/api/config', async (c) => {
241
389
  try {
@@ -787,16 +935,93 @@ app.post('/api/workspace/suggest-workflow', async (c) => {
787
935
  return errorResponse(c, error);
788
936
  }
789
937
  });
790
- // 8. Open workspace in editor
938
+ // 8. List the closed, server-owned launch catalog.
939
+ app.get('/api/workspace-launch-targets', async (c) => {
940
+ try {
941
+ return c.json(await detectWorkspaceLaunchTargets());
942
+ }
943
+ catch (error) {
944
+ return errorResponse(c, error);
945
+ }
946
+ });
947
+ // 8a. Open a real NexusFlow workspace in a selected desktop app or editor.
948
+ app.post('/api/workspace/:id/launch', async (c) => {
949
+ try {
950
+ const id = decodeURIComponent(c.req.param('id'));
951
+ const { targetId, action = 'new', sessionId } = await c.req.json().catch(() => ({}));
952
+ if (typeof targetId !== 'string' || !targetId) {
953
+ return c.json({ error: 'A workspace launch target is required.' }, 400);
954
+ }
955
+ if (action !== 'new' && action !== 'resume') {
956
+ return c.json({ error: 'Unknown workspace launch action.' }, 400);
957
+ }
958
+ if (action === 'new' && sessionId !== undefined) {
959
+ return c.json({ error: 'A new workspace launch cannot include a session id.' }, 400);
960
+ }
961
+ const config = await loadConfig();
962
+ const requestedWorkspacePath = resolveWorkspacePath(config.workspacesDir, id);
963
+ let workspacePath;
964
+ try {
965
+ workspacePath = await resolveExactLaunchWorkspace(config.workspacesDir, requestedWorkspacePath);
966
+ }
967
+ catch (error) {
968
+ if (error instanceof PathAccessError)
969
+ throw error;
970
+ return c.json({ error: 'Workspace configuration not found.' }, 404);
971
+ }
972
+ if (!workspacePath) {
973
+ return c.json({ error: 'Workspace configuration not found.' }, 404);
974
+ }
975
+ const feature = await loadWorkspaceManifest(workspacePath);
976
+ if (!feature) {
977
+ return c.json({ error: 'Workspace configuration not found.' }, 404);
978
+ }
979
+ const targets = await detectWorkspaceLaunchTargets();
980
+ const target = targets.find((candidate) => candidate.id === targetId);
981
+ if (!target)
982
+ return c.json({ error: 'Unknown workspace launch target.' }, 400);
983
+ if (!target.available) {
984
+ return c.json({ error: target.unavailableReason ?? `${target.name} is unavailable.` }, 409);
985
+ }
986
+ if (action === 'resume') {
987
+ if (targetId !== 'codex-desktop') {
988
+ return c.json({ error: 'This app cannot open an existing coding session.' }, 400);
989
+ }
990
+ if (typeof sessionId !== 'string' || !isValidSessionUuid(sessionId)) {
991
+ return c.json({ error: 'A valid Codex session id is required.' }, 400);
992
+ }
993
+ const ownsSession = await canOpenCodexSessionInWorkspace(workspacePath, feature.repos, sessionId);
994
+ if (!ownsSession) {
995
+ return c.json({ error: 'Codex session not found in this workspace.' }, 404);
996
+ }
997
+ await launchWorkspaceTarget(targetId, workspacePath, { kind: 'resume-session', sessionId });
998
+ return c.json({ success: true, targetId, action, sessionId });
999
+ }
1000
+ await launchWorkspaceTarget(targetId, workspacePath, {
1001
+ kind: 'new-workspace',
1002
+ prompt: buildWorkspaceLaunchPrompt(feature),
1003
+ });
1004
+ return c.json({ success: true, targetId, action });
1005
+ }
1006
+ catch (error) {
1007
+ return errorResponse(c, error);
1008
+ }
1009
+ });
1010
+ // 8b. Legacy editor route retained for older GUI clients using recognized
1011
+ // graphical editors. Interactive terminal editors are intentionally rejected:
1012
+ // a detached HTTP request cannot safely provide their required TTY.
791
1013
  app.post('/api/open-editor', async (c) => {
792
1014
  try {
793
1015
  const { workspacePath, command } = await c.req.json();
794
- if (!ALLOWED_EDITORS.has(command)) {
1016
+ const targetId = launchTargetIdForEditorCommand(command);
1017
+ if (!ALLOWED_EDITORS.has(command) || !targetId) {
795
1018
  return c.json({ error: 'Forbidden editor command' }, 400);
796
1019
  }
797
- // Validate path exists and is a directory
1020
+ const config = await loadConfig();
1021
+ const safeWorkspacePath = assertWithin(config.workspacesDir, workspacePath);
1022
+ // Validate this is an existing NexusFlow workspace, not an arbitrary path.
798
1023
  try {
799
- const stats = await fs.stat(workspacePath);
1024
+ const stats = await fs.stat(safeWorkspacePath);
800
1025
  if (!stats.isDirectory()) {
801
1026
  return c.json({ error: 'Workspace path is not a directory' }, 400);
802
1027
  }
@@ -804,18 +1029,11 @@ app.post('/api/open-editor', async (c) => {
804
1029
  catch {
805
1030
  return c.json({ error: 'Workspace path does not exist' }, 400);
806
1031
  }
807
- // Spawn editor process
808
- const isWin = process.platform === 'win32';
809
- const child = execa(command, [workspacePath], {
810
- detached: true,
811
- stdio: 'ignore',
812
- shell: isWin,
813
- cleanup: false,
814
- });
815
- child.unref();
816
- child.catch((err) => {
817
- console.error(`Failed to launch editor ${command} for path ${workspacePath}:`, err);
818
- });
1032
+ const exactWorkspacePath = await resolveExactLaunchWorkspace(config.workspacesDir, safeWorkspacePath);
1033
+ if (!exactWorkspacePath) {
1034
+ return c.json({ error: 'Workspace configuration not found.' }, 404);
1035
+ }
1036
+ await launchWorkspaceTarget(targetId, exactWorkspacePath);
819
1037
  return c.json({ success: true });
820
1038
  }
821
1039
  catch (error) {
@@ -1380,6 +1598,9 @@ app.get('/api/session/:assistant/:sessionId/transcript', async (c) => {
1380
1598
  const assistant = c.req.param('assistant');
1381
1599
  const sessionId = c.req.param('sessionId');
1382
1600
  try {
1601
+ if (assistant === 'codex' && !isValidSessionUuid(sessionId)) {
1602
+ return c.json({ error: 'Invalid Codex session id.' }, 400);
1603
+ }
1383
1604
  const messages = await getSessionTranscript(assistant, sessionId);
1384
1605
  return c.json({ messages });
1385
1606
  }
@@ -1559,6 +1780,7 @@ app.post('/api/workflows/templates/:id/analyze', async (c) => {
1559
1780
  const selectedAssistant = assistant || 'antigravity';
1560
1781
  let command = '';
1561
1782
  let args = [];
1783
+ let commandInput = '';
1562
1784
  let prompt = `You are an expert AI system engineering reviewer. Analyze the following Agent Teamwork Strategy guidelines.
1563
1785
  Evaluate its instructions, identify any ambiguities or contradictions, rate its expected effectiveness for orchestrating subagents, and provide specific recommendations or improvements. Format your analysis in clean Markdown with clear headings (e.g. Overview, Strengths, Weaknesses, Recommendations).
1564
1786
 
@@ -1585,11 +1807,17 @@ and suffix it with:
1585
1807
  else if (command === 'agy') {
1586
1808
  args = [prompt];
1587
1809
  }
1810
+ else if (command === 'codex') {
1811
+ // Captured stdio cannot host the interactive TUI. `codex exec` is the
1812
+ // supported non-interactive surface and reuses the user's CLI login.
1813
+ args = ['exec', '--color', 'never', '-'];
1814
+ commandInput = prompt;
1815
+ }
1588
1816
  else {
1589
1817
  args = [prompt];
1590
1818
  }
1591
1819
  const result = await execa(command, args, {
1592
- input: '',
1820
+ input: commandInput,
1593
1821
  shell: false,
1594
1822
  reject: false
1595
1823
  });