orquesta-agent 0.2.246 → 0.2.248

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.
package/dist/executor.js CHANGED
@@ -119,6 +119,8 @@ import { fileURLToPath, pathToFileURL } from 'url';
119
119
  import { createRequire } from 'module';
120
120
  import * as logger from './logger.js';
121
121
  import { redactSecrets } from './redact.js';
122
+ import { materializeSecretFiles } from './secret-files.js';
123
+ import { InteractiveStallDetector, describeInteractiveStall, stripAnsi as stripAnsiForResult } from './interactive-prompt.js';
122
124
  // ESM require for resolving the bundled orquesta-cli entry on Windows (see the
123
125
  // interactive-session spawn — node-pty can't launch the `.cmd` shim).
124
126
  const nodeRequire = createRequire(import.meta.url);
@@ -155,6 +157,22 @@ function cleanKimiText(str) {
155
157
  .map(line => line.replace(/^(\s*)•\s/, '$1'))
156
158
  .join('\n');
157
159
  }
160
+ // kiro-cli's headless `chat` output is a TUI transcript, not a plain answer:
161
+ // ANSI colour throughout, a trust banner when --trust-all-tools is on ("All
162
+ // tools are now trusted (!). …", "Agents can sometimes do unexpected things…",
163
+ // "Learn more at https://kiro.dev/docs/cli/chat/security/…"), the answer
164
+ // prefixed with the `> ` prompt marker, and a "▸ Credits: 0.05 • Time: 2s"
165
+ // footer. Strip the chrome so the timeline shows the answer.
166
+ function cleanKiroText(str) {
167
+ return stripAnsiCodes(str)
168
+ .split('\n')
169
+ .filter(line => !/^\s*All tools are now trusted/.test(line))
170
+ .filter(line => !/^\s*Agents can sometimes do unexpected things/.test(line))
171
+ .filter(line => !/^\s*Learn more at https:\/\/kiro\.dev\//.test(line))
172
+ .filter(line => !/^\s*▸?\s*Credits:\s*[\d.]+\s*•\s*Time:/.test(line))
173
+ .map(line => line.replace(/^>\s?/, ''))
174
+ .join('\n');
175
+ }
158
176
  function stripAnsiCodes(str) {
159
177
  // Remove OSC sequences first (ESC ] ... BEL or ESC ] ... ESC \)
160
178
  // Windows Terminal / ConEmu emit OSC 9;4;<state>;<progress> for progress markers.
@@ -236,6 +254,18 @@ const promptCostCents = new Map();
236
254
  // ever saw logs, and panels that read prompts.result (architecture extraction,
237
255
  // auto-agent polling) found it empty.
238
256
  const promptFinalResult = new Map();
257
+ // promptId -> failure text when a run was cut for waiting on a keyboard prompt
258
+ // (see interactive-prompt.ts). Read by the close handler.
259
+ const promptInteractiveStall = new Map();
260
+ // How long a prompt-looking line may sit with no further output before the
261
+ // run is killed as "waiting for input". 0 disables the watchdog.
262
+ function interactivePromptTimeoutMs() {
263
+ const raw = process.env.ORQUESTA_INTERACTIVE_PROMPT_TIMEOUT_MS;
264
+ if (raw === undefined || raw === '')
265
+ return 60_000;
266
+ const n = Number(raw);
267
+ return Number.isFinite(n) && n >= 0 ? n : 60_000;
268
+ }
239
269
  // The CLI's own verdict for a run, from its `end` event. Kept per prompt because
240
270
  // several prompts stream through the same process concurrently. See QA-76: an
241
271
  // exit code is not evidence that the work succeeded.
@@ -343,6 +373,18 @@ export function setInjectedCredentials(credentials) {
343
373
  export function getInjectedCredentials() {
344
374
  return injectedCredentials;
345
375
  }
376
+ // Write the project's secret files to disk (relative paths resolve against
377
+ // `cwd`) and return the credentials plus one `ORQUESTA_FILE_<NAME>` var per
378
+ // file — listed in the manifest too, so orquesta-cli's env whitelist lets the
379
+ // shell tools see them.
380
+ function withSecretFileEnv(credentials, cwd) {
381
+ const fileEnv = materializeSecretFiles(cwd);
382
+ const fileKeys = Object.keys(fileEnv);
383
+ if (fileKeys.length === 0)
384
+ return credentials;
385
+ const manifest = [credentials[INJECTED_ENV_MANIFEST], ...fileKeys].filter(Boolean).join(',');
386
+ return { ...credentials, ...fileEnv, [INJECTED_ENV_MANIFEST]: manifest };
387
+ }
346
388
  let globalPermissionMode = 'auto';
347
389
  // Agent instructions (from Orquesta dashboard)
348
390
  let agentInstructions = null;
@@ -1037,6 +1079,87 @@ export function checkCursorAuth() {
1037
1079
  return cache({ available: true, authenticated: true, method: null });
1038
1080
  }
1039
1081
  }
1082
+ // Resolve AWS Kiro's CLI (`kiro-cli`). Its installer drops the binary in
1083
+ // ~/.local/bin (alongside `kiro-cli-chat`/`kiro-cli-term`) and, like cursor's,
1084
+ // only reminds the user to put that on PATH — so probe the install dir too.
1085
+ // Order: KIRO_CLI_PATH → on PATH → ~/.local/bin/kiro-cli.
1086
+ // macOS + Linux only: Kiro ships no native Windows CLI, so on win32 this is
1087
+ // always null and the engine never appears as available there.
1088
+ let kiroBinaryCache = null;
1089
+ export function resolveKiroBinary() {
1090
+ const now = Date.now();
1091
+ if (kiroBinaryCache && now - kiroBinaryCache.at < CLI_DETECTION_TTL_MS)
1092
+ return kiroBinaryCache.path;
1093
+ let resolved = null;
1094
+ if (process.platform !== 'win32') {
1095
+ const envPath = process.env.KIRO_CLI_PATH;
1096
+ if (envPath) {
1097
+ try {
1098
+ fs.accessSync(envPath, fs.constants.X_OK);
1099
+ resolved = envPath;
1100
+ }
1101
+ catch { /* bad override */ }
1102
+ }
1103
+ if (!resolved) {
1104
+ try {
1105
+ execSync('kiro-cli --version', { stdio: 'pipe', timeout: 15000 });
1106
+ resolved = 'kiro-cli';
1107
+ }
1108
+ catch { /* not on PATH */ }
1109
+ }
1110
+ if (!resolved) {
1111
+ const candidate = path.join(os.homedir(), '.local', 'bin', 'kiro-cli');
1112
+ try {
1113
+ fs.accessSync(candidate, fs.constants.X_OK);
1114
+ resolved = candidate;
1115
+ }
1116
+ catch { /* not installed there */ }
1117
+ }
1118
+ }
1119
+ kiroBinaryCache = { path: resolved, at: now };
1120
+ return resolved;
1121
+ }
1122
+ // Kiro CLI (the `kiro-cli` binary). Authenticated separately with
1123
+ // `kiro-cli login` against the user's own AWS Builder ID / IAM Identity Center
1124
+ // — Orquesta never sees that credential.
1125
+ export function isKiroCliAvailable() {
1126
+ return resolveKiroBinary() !== null;
1127
+ }
1128
+ // Is kiro-cli actually LOGGED IN, not just installed? Same contract as
1129
+ // checkCursorAuth: `kiro-cli whoami` exits 0 with the identity when logged in
1130
+ // and exits 1 printing "Not logged in" otherwise. Only that wording flips the
1131
+ // flag; anything else (timeout, older build) reports authenticated with a null
1132
+ // method, because execution never depends on this flag.
1133
+ let kiroAuthCache = null;
1134
+ export function checkKiroAuth() {
1135
+ const now = Date.now();
1136
+ if (kiroAuthCache && now - kiroAuthCache.at < CLI_DETECTION_TTL_MS)
1137
+ return kiroAuthCache.result;
1138
+ const cache = (result) => {
1139
+ kiroAuthCache = { result, at: now };
1140
+ return result;
1141
+ };
1142
+ const binary = resolveKiroBinary();
1143
+ if (!binary)
1144
+ return cache({ available: false, authenticated: false, method: null });
1145
+ try {
1146
+ const out = execSync(`"${binary}" whoami`, {
1147
+ stdio: ['ignore', 'pipe', 'pipe'],
1148
+ timeout: 15000,
1149
+ encoding: 'utf-8',
1150
+ });
1151
+ if (isLoggedOutMessage(out))
1152
+ return cache({ available: true, authenticated: false, method: null });
1153
+ return cache({ available: true, authenticated: true, method: 'subscription' });
1154
+ }
1155
+ catch (error) {
1156
+ const err = error;
1157
+ const combined = `${err.stdout ?? ''}${err.stderr ?? ''}`;
1158
+ if (isLoggedOutMessage(combined))
1159
+ return cache({ available: true, authenticated: false, method: null });
1160
+ return cache({ available: true, authenticated: true, method: null });
1161
+ }
1162
+ }
1040
1163
  // Dedupe the fallback warning: selectCli() runs on every heartbeat (the agent
1041
1164
  // reports activeCli), so logging unconditionally spammed thousands of identical
1042
1165
  // WARN lines. Only log when the message actually changes.
@@ -1051,12 +1174,13 @@ function warnCliOnce(msg) {
1051
1174
  // the pinned CLI turns out not to be installed. Table-driven rather than a nest
1052
1175
  // of pairwise ifs: every CLI added here otherwise costs another N branches, and
1053
1176
  // the pair that got missed is exactly how a preference silently no-ops.
1054
- const CLI_AUTO_ORDER = ['orquesta', 'claude', 'kimi', 'cursor'];
1177
+ const CLI_AUTO_ORDER = ['orquesta', 'claude', 'kimi', 'cursor', 'kiro'];
1055
1178
  const CLI_AUTO_REASON = {
1056
1179
  orquesta: 'Auto: orquesta available (local LLM)',
1057
1180
  claude: 'Auto: claude available (Anthropic API)',
1058
1181
  kimi: 'Auto: kimi available',
1059
1182
  cursor: 'Auto: cursor-agent available',
1183
+ kiro: 'Auto: kiro-cli available',
1060
1184
  };
1061
1185
  // Select which CLI to use based on availability and preference
1062
1186
  export function selectCli() {
@@ -1065,6 +1189,7 @@ export function selectCli() {
1065
1189
  claude: isClaudeCliAvailable(),
1066
1190
  kimi: isKimiCliAvailable(),
1067
1191
  cursor: isCursorCliAvailable(),
1192
+ kiro: isKiroCliAvailable(),
1068
1193
  };
1069
1194
  if (!CLI_AUTO_ORDER.some(cli => available[cli])) {
1070
1195
  return { cli: null, reason: `No CLI found (${CLI_AUTO_ORDER.join(', ')})` };
@@ -2201,7 +2326,7 @@ ${userRequestBody}`;
2201
2326
  // Build environment with authentication
2202
2327
  const env = {
2203
2328
  ...process.env,
2204
- ...injectedCredentials, // Inject credentials from Orquesta
2329
+ ...withSecretFileEnv(injectedCredentials, workingDirectory || process.cwd()), // Inject credentials from Orquesta
2205
2330
  CI: 'true', // Ensure non-interactive mode
2206
2331
  // Expose the current prompt id so Claude can link generated files back
2207
2332
  // to this prompt via /api/agent/files (prompt_id=$ORQUESTA_PROMPT_ID).
@@ -2258,9 +2383,11 @@ ${userRequestBody}`;
2258
2383
  ? (resolveKimiBinary() || 'kimi')
2259
2384
  : cliCommand === 'cursor'
2260
2385
  ? (resolveCursorBinary() || 'cursor-agent')
2261
- : cliCommand === 'claude'
2262
- ? (resolveClaudeBinary() || 'claude')
2263
- : (cliCommand || 'claude');
2386
+ : cliCommand === 'kiro'
2387
+ ? (resolveKiroBinary() || 'kiro-cli')
2388
+ : cliCommand === 'claude'
2389
+ ? (resolveClaudeBinary() || 'claude')
2390
+ : (cliCommand || 'claude');
2264
2391
  const cwd = workingDirectory || process.cwd();
2265
2392
  // Which branch this prompt runs on — captured up front so it's persisted even
2266
2393
  // if the run fails, and broadcast live below once sendSpecialEvent exists.
@@ -2272,16 +2399,16 @@ ${userRequestBody}`;
2272
2399
  let detectedCommitSha = null;
2273
2400
  let detectedCommitUrl = null;
2274
2401
  logger.info(`CLI Selection: ${cliCommand} (${reason})`);
2275
- logger.info(`Spawning: ${cliBinary} -p "${fullContent.slice(0, 50)}..." in ${cwd}${currentBranch ? ` on branch ${currentBranch}` : ''}`);
2402
+ logger.info(`Spawning: ${cliBinary} ${cliCommand === 'kiro' ? 'chat' : '-p'} "${fullContent.slice(0, 50)}..." in ${cwd}${currentBranch ? ` on branch ${currentBranch}` : ''}`);
2276
2403
  // Hoist the static block into the CLI system prompt (claude/orquesta support
2277
2404
  // --append-system-prompt-file; kimi has no such flag and stays inline). On any
2278
2405
  // write failure we fall back to the inline fullContent, so a dispatched prompt
2279
2406
  // can never regress to a worse state than before.
2280
2407
  let promptContent = fullContent;
2281
2408
  let sysPromptFilePath = null;
2282
- // kimi and cursor-agent have no --append-system-prompt-file, so their static
2283
- // context stays inline in the prompt.
2284
- if (cliCommand !== 'kimi' && cliCommand !== 'cursor') {
2409
+ // kimi, cursor-agent and kiro-cli have no --append-system-prompt-file, so
2410
+ // their static context stays inline in the prompt.
2411
+ if (cliCommand !== 'kimi' && cliCommand !== 'cursor' && cliCommand !== 'kiro') {
2285
2412
  try {
2286
2413
  const promptDir = process.platform === 'win32'
2287
2414
  ? os.tmpdir()
@@ -2314,22 +2441,29 @@ ${userRequestBody}`;
2314
2441
  // below only understands Claude's and orquesta-cli's,
2315
2442
  // so asking for it would give us an unparseable
2316
2443
  // stream instead of an answer.
2317
- : cliCommand === 'kimi'
2318
- ? '--output-format text' // Kimi: plain text. NOTE: -p (one-shot) CANNOT be
2319
- // combined with -y ("Cannot combine --prompt with
2320
- // --yolo"), so dispatched kimi prompts respond as
2321
- // chat (no autonomous tool use). Interactive kimi
2322
- // sessions DO get -y (see ptyArgs below).
2323
- : '--verbose --output-format stream-json'; // Claude: stream-json format
2444
+ : cliCommand === 'kiro'
2445
+ ? '--no-interactive --wrap never' // Kiro: `chat` is interactive by default;
2446
+ // --no-interactive answers once and exits, --wrap
2447
+ // never stops it re-flowing the answer to the
2448
+ // (absent) terminal width.
2449
+ : cliCommand === 'kimi'
2450
+ ? '--output-format text' // Kimi: plain text. NOTE: -p (one-shot) CANNOT be
2451
+ // combined with -y ("Cannot combine --prompt with
2452
+ // --yolo"), so dispatched kimi prompts respond as
2453
+ // chat (no autonomous tool use). Interactive kimi
2454
+ // sessions DO get -y (see ptyArgs below).
2455
+ : '--verbose --output-format stream-json'; // Claude: stream-json format
2324
2456
  // Kimi has no --dangerously-skip-permissions, and -p forbids -y. Cursor spells
2325
- // the same thing --force (alias --yolo).
2457
+ // the same thing --force (alias --yolo); kiro spells it --trust-all-tools.
2326
2458
  const permFlags = cliCommand === 'kimi'
2327
2459
  ? ''
2328
2460
  : mode !== 'auto'
2329
2461
  ? ''
2330
2462
  : cliCommand === 'cursor'
2331
2463
  ? '--force'
2332
- : '--dangerously-skip-permissions';
2464
+ : cliCommand === 'kiro'
2465
+ ? '--trust-all-tools'
2466
+ : '--dangerously-skip-permissions';
2333
2467
  // Per-project endpoint override (orquesta CLI only). Single-quoted for the
2334
2468
  // shell-wrapped (Linux) path; pushed as a separate arg on Win/macOS below.
2335
2469
  const useEndpoint = cliCommand === 'orquesta' && !!globalCliEndpoint;
@@ -2341,7 +2475,10 @@ ${userRequestBody}`;
2341
2475
  // Claude CLI's headless `-p` mode does not accept positional file args as
2342
2476
  // attachments — image refs are surfaced inline in the prompt as absolute
2343
2477
  // paths so Claude's Read tool can pick them up.
2344
- command = `${cliBinary} -p '${escapedContent}' ${permFlags} ${baseFlags} ${endpointFlag} ${sysPromptFlag}`;
2478
+ // kiro-cli has no `-p`: the one-shot form is `kiro-cli chat "<prompt>"` with
2479
+ // --no-interactive (baseFlags), the prompt as the verb's positional arg.
2480
+ const promptVerb = cliCommand === 'kiro' ? 'chat' : '-p';
2481
+ command = `${cliBinary} ${promptVerb} '${escapedContent}' ${permFlags} ${baseFlags} ${endpointFlag} ${sysPromptFlag}`;
2345
2482
  if (imageReferences.length > 0) {
2346
2483
  logger.info(`Image attachments referenced inline: ${imageReferences.length}`);
2347
2484
  }
@@ -2354,7 +2491,7 @@ ${userRequestBody}`;
2354
2491
  const isDarwin = process.platform === 'darwin';
2355
2492
  let claude;
2356
2493
  if (isWindows || isDarwin) {
2357
- const args = ['-p'];
2494
+ const args = [promptVerb];
2358
2495
  // Windows cmd.exe caps the whole command line at ~8191 chars. The prompt is
2359
2496
  // prefixed with large context blocks (capabilities, project CLAUDE.md,
2360
2497
  // coordination…), so passing it as an argv overflows that limit and the CLI
@@ -2414,6 +2551,16 @@ ${userRequestBody}`;
2414
2551
  let thinkingBuffer = '';
2415
2552
  let textBuffer = '';
2416
2553
  let toolInputBuffer = '';
2554
+ // Fallbacks for the stored result when the CLI dies without a result event
2555
+ // (killed, crashed, cut for an interactive prompt): the last prose the model
2556
+ // produced, else the tail of whatever raw output there was.
2557
+ let lastAssistantText = '';
2558
+ let rawOutputTail = '';
2559
+ const noteRawOutput = (text) => {
2560
+ const clean = stripAnsiForResult(text);
2561
+ if (clean.trim())
2562
+ rawOutputTail = (rawOutputTail + clean).slice(-4000);
2563
+ };
2417
2564
  // Parse stream-json output format
2418
2565
  // Returns { output: string, hadValidJson: boolean, logs: AgentLogEntry[] }
2419
2566
  const parseStreamJson = (text) => {
@@ -2495,6 +2642,7 @@ ${userRequestBody}`;
2495
2642
  output += `\n${content}\n`;
2496
2643
  logs.push(createOutputLog(content, 'markdown', 'info', promptId));
2497
2644
  promptFinalResult.set(promptId, String(content));
2645
+ lastAssistantText = String(content);
2498
2646
  }
2499
2647
  }
2500
2648
  else if (json.event === 'error') {
@@ -2537,6 +2685,8 @@ ${userRequestBody}`;
2537
2685
  // separate the model's prose from a preceding tool result
2538
2686
  // (the blob otherwise leaves them adjacent with no delimiter).
2539
2687
  output += `\n💬 ${block.text}\n`;
2688
+ if (typeof block.text === 'string' && block.text.trim())
2689
+ lastAssistantText = block.text;
2540
2690
  // Create structured output log
2541
2691
  if (block.text.trim()) {
2542
2692
  logs.push(createOutputLog(block.text, 'markdown', 'info', promptId));
@@ -2639,6 +2789,7 @@ ${userRequestBody}`;
2639
2789
  }
2640
2790
  if (textBuffer.trim()) {
2641
2791
  logs.push(createOutputLog(textBuffer, 'markdown', 'info', promptId));
2792
+ lastAssistantText = textBuffer;
2642
2793
  textBuffer = '';
2643
2794
  }
2644
2795
  if (toolInputBuffer.trim() && currentToolName) {
@@ -2739,9 +2890,44 @@ ${userRequestBody}`;
2739
2890
  sendSpecialEvent('git_branch', { branch: currentBranch });
2740
2891
  }
2741
2892
  // Stream stdout
2893
+ // Watchdog for a CLI (or a tool it ran with a real TTY) that stops to ask a
2894
+ // question. A headless run can never answer, so after `silenceMs` of quiet
2895
+ // following a prompt-looking line we kill it and record WHY — the old
2896
+ // behaviour was to sit until the dispatch timeout and store an empty
2897
+ // result over a log of TUI redraw garbage (`eas login`, 2026-09).
2898
+ const stallSilenceMs = interactivePromptTimeoutMs();
2899
+ const stallDetector = new InteractiveStallDetector({
2900
+ silenceMs: stallSilenceMs,
2901
+ onStall: (label, tail) => {
2902
+ if (!runningProcesses.has(commandId))
2903
+ return;
2904
+ const message = describeInteractiveStall(label, tail, stallSilenceMs);
2905
+ logger.warn(`Interactive prompt detected (${label}) with no output for ${Math.round(stallSilenceMs / 1000)}s — killing run`);
2906
+ promptInteractiveStall.set(promptId, message);
2907
+ addLog(promptId, 'error', 'system', message);
2908
+ sendOutput(channel, commandId, 'stderr', `\n❌ ${message}\n`);
2909
+ try {
2910
+ claude.kill('SIGTERM');
2911
+ }
2912
+ catch { /* already gone */ }
2913
+ setTimeout(() => {
2914
+ if (runningProcesses.has(commandId)) {
2915
+ try {
2916
+ claude.kill('SIGKILL');
2917
+ }
2918
+ catch { /* already gone */ }
2919
+ }
2920
+ }, 5000).unref?.();
2921
+ },
2922
+ });
2742
2923
  if (claude.stdout) {
2743
2924
  claude.stdout.on('data', (data) => {
2744
- const text = cliCommand === 'kimi' ? cleanKimiText(data.toString()) : data.toString();
2925
+ const text = cliCommand === 'kimi'
2926
+ ? cleanKimiText(data.toString())
2927
+ : cliCommand === 'kiro'
2928
+ ? cleanKiroText(data.toString())
2929
+ : data.toString();
2930
+ stallDetector.feed(data.toString());
2745
2931
  // Skip logging if it looks like base64/binary data (long strings without spaces)
2746
2932
  const preview = text.slice(0, 150);
2747
2933
  const looksLikeBinary = preview.length > 50 && !preview.includes(' ') && /^[A-Za-z0-9+/=]+$/.test(preview.replace(/\s/g, ''));
@@ -2756,6 +2942,8 @@ ${userRequestBody}`;
2756
2942
  // Only fallback to raw text if it wasn't valid JSON
2757
2943
  // If it was valid JSON but produced no output (e.g., system init), don't send anything
2758
2944
  const outputToSend = hadValidJson ? parsed : text;
2945
+ if (!hadValidJson)
2946
+ noteRawOutput(text);
2759
2947
  if (outputToSend.trim()) {
2760
2948
  logger.output('stdout', outputToSend.slice(0, 200));
2761
2949
  sendOutput(channel, commandId, 'stdout', outputToSend);
@@ -2867,9 +3055,11 @@ ${userRequestBody}`;
2867
3055
  if (claude.stderr) {
2868
3056
  claude.stderr.on('data', (data) => {
2869
3057
  const raw = data.toString();
3058
+ stallDetector.feed(raw);
2870
3059
  const text = stripAnsiCodes(raw);
2871
3060
  if (!text.trim())
2872
3061
  return;
3062
+ noteRawOutput(text);
2873
3063
  logger.info(`[STDERR] ${text.slice(0, 100)}`);
2874
3064
  logger.output('stderr', text);
2875
3065
  sendOutput(channel, commandId, 'stderr', text);
@@ -2885,6 +3075,7 @@ ${userRequestBody}`;
2885
3075
  // Handle completion
2886
3076
  claude.on('close', async (code) => {
2887
3077
  runningProcesses.delete(commandId);
3078
+ stallDetector.stop();
2888
3079
  // Remove the hoisted system-prompt temp file now that the CLI has read it.
2889
3080
  if (sysPromptFilePath) {
2890
3081
  try {
@@ -2946,7 +3137,12 @@ ${userRequestBody}`;
2946
3137
  if (cliReportedFailure && (code ?? 0) === 0) {
2947
3138
  logger.warn('CLI reported a failed run but exited 0 — recording it as failed');
2948
3139
  }
2949
- const exitCode = (claudeApiError || cliReportedFailure) ? (code || 1) : (code ?? 0);
3140
+ // A run cut for waiting on a keyboard prompt is a failure whatever the
3141
+ // signal-death exit code says (a SIGTERM'd child reports code null).
3142
+ const interactiveStall = promptId ? promptInteractiveStall.get(promptId) : undefined;
3143
+ if (promptId)
3144
+ promptInteractiveStall.delete(promptId);
3145
+ const exitCode = (claudeApiError || cliReportedFailure || interactiveStall) ? (code || 1) : (code ?? 0);
2950
3146
  const duration = Date.now() - startTime;
2951
3147
  const status = exitCode === 0 ? 'completed' : 'failed';
2952
3148
  if (exitCode === 0) {
@@ -2994,8 +3190,25 @@ ${userRequestBody}`;
2994
3190
  promptCostCents.delete(promptId);
2995
3191
  }
2996
3192
  // Update prompt status in database with summary and usage (backend fix - don't rely on frontend)
2997
- const finalResult = promptFinalResult.get(promptId);
3193
+ // Prefer the CLI's own result event; when the run died without one
3194
+ // (killed, crashed, cut for a prompt) still store SOMETHING readable —
3195
+ // the stall explanation, the last assistant prose, or the raw tail —
3196
+ // so the dashboard never shows an empty result over a dead run.
3197
+ let finalResult = promptFinalResult.get(promptId);
2998
3198
  promptFinalResult.delete(promptId);
3199
+ if (!finalResult || !finalResult.trim()) {
3200
+ if (interactiveStall) {
3201
+ finalResult = lastAssistantText.trim()
3202
+ ? `${interactiveStall}\n\nLast assistant message:\n${lastAssistantText.trim()}`
3203
+ : interactiveStall;
3204
+ }
3205
+ else if (lastAssistantText.trim()) {
3206
+ finalResult = lastAssistantText.trim();
3207
+ }
3208
+ else if (rawOutputTail.trim()) {
3209
+ finalResult = `${status === 'failed' ? `Run failed (exit code ${exitCode}) without a result. ` : ''}Last output:\n${rawOutputTail.trim().split('\n').slice(-40).join('\n')}`;
3210
+ }
3211
+ }
2999
3212
  // Record WHICH CLI + model executed it (timeline/history). Model is
3000
3213
  // best-effort — the CLI/proxy pick the true upstream model internally
3001
3214
  // (batuta-auto fans out per prompt), so we report the CLI's MODEL CONFIG:
@@ -3005,13 +3218,16 @@ ${userRequestBody}`;
3005
3218
  ? 'kimi-cli'
3006
3219
  : cliCommand === 'cursor'
3007
3220
  ? 'cursor-cli'
3008
- : 'claude-cli';
3221
+ : cliCommand === 'kiro'
3222
+ ? 'kiro-cli'
3223
+ : 'claude-cli';
3009
3224
  let modelForDb;
3010
3225
  if (cliCommand === 'kimi')
3011
3226
  modelForDb = 'kimi-for-coding';
3012
3227
  // cursor-agent picks the model from the user's own Cursor settings and does
3013
- // not report it in text mode, so leave it unset rather than guess.
3014
- else if (cliCommand === 'cursor')
3228
+ // not report it in text mode, so leave it unset rather than guess. Same for
3229
+ // kiro-cli (its default model is set in the user's Kiro agent config).
3230
+ else if (cliCommand === 'cursor' || cliCommand === 'kiro')
3015
3231
  modelForDb = undefined;
3016
3232
  else if (cliCommand === 'orquesta')
3017
3233
  modelForDb = (globalCliEndpoint === 'batuta' || !globalCliEndpoint) ? 'batuta-auto' : globalCliEndpoint;
@@ -3038,6 +3254,7 @@ ${userRequestBody}`;
3038
3254
  // Handle errors
3039
3255
  claude.on('error', async (err) => {
3040
3256
  runningProcesses.delete(commandId);
3257
+ stallDetector.stop();
3041
3258
  if (promptId)
3042
3259
  promptIdToCommandId.delete(promptId);
3043
3260
  pendingSupervisionCallbacks.delete(commandId);
@@ -3858,7 +4075,7 @@ export async function startSession(options) {
3858
4075
  // Build environment with authentication
3859
4076
  const env = {
3860
4077
  ...process.env,
3861
- ...injectedCredentials,
4078
+ ...withSecretFileEnv(injectedCredentials, cwd),
3862
4079
  // Don't set CI=true for interactive mode - we want full TTY behavior
3863
4080
  };
3864
4081
  if (anthropicApiKey) {
@@ -3888,10 +4105,12 @@ export async function startSession(options) {
3888
4105
  ? (resolveKimiBinary() || 'kimi')
3889
4106
  : cliCommand === 'cursor'
3890
4107
  ? (resolveCursorBinary() || 'cursor-agent')
3891
- : cliCommand === 'claude'
3892
- ? (resolveClaudeBinary() || 'claude')
3893
- : cliCommand;
3894
- const resolvedByPath = cliCommand === 'kimi' || cliCommand === 'cursor';
4108
+ : cliCommand === 'kiro'
4109
+ ? (resolveKiroBinary() || 'kiro-cli')
4110
+ : cliCommand === 'claude'
4111
+ ? (resolveClaudeBinary() || 'claude')
4112
+ : cliCommand;
4113
+ const resolvedByPath = cliCommand === 'kimi' || cliCommand === 'cursor' || cliCommand === 'kiro';
3895
4114
  logger.info(`Interactive session CLI: ${cliCommand}${resolvedByPath ? ` (${cliBinary})` : ''}`);
3896
4115
  // Best-effort model + cli label reported in session:started so the dashboard
3897
4116
  // can tag the interactive prompt (same mapping dispatched prompts use on
@@ -3899,8 +4118,8 @@ export async function startSession(options) {
3899
4118
  // back to ANTHROPIC_MODEL when set, else undefined.
3900
4119
  const sessionModel = cliCommand === 'kimi'
3901
4120
  ? 'kimi-for-coding'
3902
- : cliCommand === 'cursor'
3903
- // cursor-agent uses whatever model the user selected in their own Cursor
4121
+ : cliCommand === 'cursor' || cliCommand === 'kiro'
4122
+ // cursor-agent / kiro-cli use whatever model the user selected in their own
3904
4123
  // account; it isn't knowable from the PTY, so report nothing rather than lie.
3905
4124
  ? undefined
3906
4125
  : cliCommand === 'orquesta'
@@ -3909,7 +4128,8 @@ export async function startSession(options) {
3909
4128
  const sessionCliType = cliCommand === 'orquesta' ? 'orquesta'
3910
4129
  : cliCommand === 'kimi' ? 'kimi'
3911
4130
  : cliCommand === 'cursor' ? 'cursor'
3912
- : 'claude';
4131
+ : cliCommand === 'kiro' ? 'kiro'
4132
+ : 'claude';
3913
4133
  // Filter undefined env values — node-pty requires Record<string, string>.
3914
4134
  // In strict sandbox mode, sandboxEnv() first reduces to the allowlist so the
3915
4135
  // interactive CLI can't read unrelated host secrets from its own env.
@@ -3997,7 +4217,7 @@ export async function startSession(options) {
3997
4217
  // `ps aux`. Default-off because a file unreadable inside bwrap would silently
3998
4218
  // strip the system prompt (degraded session, not a crash) — flip the default
3999
4219
  // only after E2E confirms the sandbox reads it. Rollback = unset the var.
4000
- const wantSysPromptFile = cliCommand !== 'kimi' && cliCommand !== 'cursor' && ((process.platform === 'win32' && cliCommand === 'orquesta') ||
4220
+ const wantSysPromptFile = cliCommand !== 'kimi' && cliCommand !== 'cursor' && cliCommand !== 'kiro' && ((process.platform === 'win32' && cliCommand === 'orquesta') ||
4001
4221
  /^(1|true|yes|on)$/i.test(process.env.ORQUESTA_SYSPROMPT_FILE || ''));
4002
4222
  if (wantSysPromptFile) {
4003
4223
  try {
@@ -4016,7 +4236,22 @@ export async function startSession(options) {
4016
4236
  }
4017
4237
  }
4018
4238
  let ptyArgs;
4019
- if (cliCommand === 'cursor') {
4239
+ if (cliCommand === 'kiro') {
4240
+ // kiro-cli has no --append-system-prompt either; it reads AGENTS.md /
4241
+ // steering files from the working dir. `chat` is the interactive verb and
4242
+ // `--trust-all-tools` its --dangerously-skip-permissions. Resume flags go
4243
+ // AFTER the verb (see below) — the generic unshift would put them before it.
4244
+ ptyArgs = ['chat', '--trust-all-tools'];
4245
+ if (resumeSessionId) {
4246
+ ptyArgs.push('--resume-id', resumeSessionId);
4247
+ logger.info(`[Session] Resuming conversation ${resumeSessionId} (--resume-id) for kiro`);
4248
+ }
4249
+ else if (resume) {
4250
+ ptyArgs.push('--resume');
4251
+ logger.info('[Session] Resuming most recent conversation in this directory (--resume) for kiro');
4252
+ }
4253
+ }
4254
+ else if (cliCommand === 'cursor') {
4020
4255
  // cursor-agent has no --append-system-prompt; it reads AGENTS.md / CLAUDE.md
4021
4256
  // from the working dir (which the agent already syncs), so the capabilities
4022
4257
  // block isn't injected. `--force` is its --dangerously-skip-permissions.
@@ -4039,7 +4274,7 @@ export async function startSession(options) {
4039
4274
  // cli.ts) both reattach the latest session non-interactively; kimi has no
4040
4275
  // resume flag, so the option is silently ignored there. Prepended so it's an
4041
4276
  // unambiguous standalone flag, separate from --append-system-prompt's value.
4042
- if ((resume || resumeSessionId) && cliCommand !== 'kimi') {
4277
+ if ((resume || resumeSessionId) && cliCommand !== 'kimi' && cliCommand !== 'kiro') {
4043
4278
  // cursor-agent spells these the same way claude does (--continue / --resume <id>).
4044
4279
  // claude can target a SPECIFIC past conversation by id (--resume <id>);
4045
4280
  // orquesta-cli only reattaches the latest (--continue). So when a specific
@@ -4183,7 +4418,7 @@ export async function startSession(options) {
4183
4418
  startTime,
4184
4419
  subAgentId,
4185
4420
  subAgentName,
4186
- cliType: cliCommand === 'orquesta' ? 'orquesta' : cliCommand === 'kimi' ? 'kimi' : 'claude',
4421
+ cliType: sessionCliType,
4187
4422
  isActive: true,
4188
4423
  pendingPhoneBlock: '',
4189
4424
  pendingPhoneTimer: null,