@elyracode/coding-agent 0.9.1 → 0.9.3

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 (36) hide show
  1. package/CHANGELOG.md +17 -2
  2. package/dist/core/agent-session.d.ts +19 -0
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +45 -0
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/session-manager.d.ts +11 -0
  7. package/dist/core/session-manager.d.ts.map +1 -1
  8. package/dist/core/session-manager.js +20 -0
  9. package/dist/core/session-manager.js.map +1 -1
  10. package/dist/core/settings-manager.d.ts +3 -0
  11. package/dist/core/settings-manager.d.ts.map +1 -1
  12. package/dist/core/settings-manager.js +8 -0
  13. package/dist/core/settings-manager.js.map +1 -1
  14. package/dist/core/slash-commands.d.ts.map +1 -1
  15. package/dist/core/slash-commands.js +9 -1
  16. package/dist/core/slash-commands.js.map +1 -1
  17. package/dist/core/system-prompt.d.ts.map +1 -1
  18. package/dist/core/system-prompt.js +3 -0
  19. package/dist/core/system-prompt.js.map +1 -1
  20. package/dist/core/tools/extension-write.d.ts +46 -0
  21. package/dist/core/tools/extension-write.d.ts.map +1 -0
  22. package/dist/core/tools/extension-write.js +227 -0
  23. package/dist/core/tools/extension-write.js.map +1 -0
  24. package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
  25. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  26. package/dist/modes/interactive/components/settings-selector.js +10 -0
  27. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  28. package/dist/modes/interactive/interactive-mode.d.ts +16 -0
  29. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  30. package/dist/modes/interactive/interactive-mode.js +372 -57
  31. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  32. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  33. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  34. package/examples/extensions/sandbox/package.json +1 -1
  35. package/examples/extensions/with-deps/package.json +1 -1
  36. package/package.json +4 -4
@@ -6,7 +6,7 @@ import * as crypto from "node:crypto";
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { getProviders, } from "@elyracode/ai";
9
+ import { completeSimple, getProviders, } from "@elyracode/ai";
10
10
  import { CombinedAutocompleteProvider, Container, fuzzyFilter, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, visibleWidth, } from "@elyracode/tui";
11
11
  import { spawn, spawnSync } from "child_process";
12
12
  import { APP_NAME, APP_TITLE, getAgentDir, getAuthPath, getBlueprintsDir, getDebugLogPath, getDocsPath, getProjectBlueprintsDir, getProjectSnippetsDir, getSelfUpdateCommand, getShareViewerUrl, getSnippetsDir, PACKAGE_NAME, VERSION, } from "../../config.js";
@@ -85,6 +85,20 @@ function isDeadTerminalError(error) {
85
85
  return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
86
86
  }
87
87
  const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage.";
88
+ const REVIEW_SYSTEM_PROMPT = `You are a senior software engineer doing a focused code review of a diff. You did not write this code and have no prior context, so judge it on its own merits.
89
+
90
+ Review for, in priority order:
91
+ 1. Correctness and bugs (logic errors, off-by-one, null/undefined, race conditions, incorrect edge-case handling)
92
+ 2. Security (injection, unsafe input handling, leaked secrets, missing authz/validation)
93
+ 3. Error handling and resource cleanup
94
+ 4. Clarity and maintainability
95
+
96
+ Rules:
97
+ - Be specific: reference the file and the exact change. Quote the relevant line when useful.
98
+ - Be concise. Prioritize the few things that matter; skip nitpicks unless nothing else is wrong.
99
+ - If the diff looks correct and well done, say so plainly rather than inventing problems.
100
+ - Do not restate the whole diff. Only report findings.
101
+ - Format as a short Markdown list grouped by severity (Critical / Important / Minor), and end with a one-line overall assessment.`;
88
102
  function isAnthropicSubscriptionAuthKey(apiKey) {
89
103
  return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
90
104
  }
@@ -2033,6 +2047,16 @@ export class InteractiveMode {
2033
2047
  await this.handleDiffCommand(text.slice("/diff".length).trim());
2034
2048
  return;
2035
2049
  }
2050
+ if (text === "/review" || text.startsWith("/review ")) {
2051
+ this.editor.setText("");
2052
+ await this.handleReviewCommand(text.slice("/review".length).trim());
2053
+ return;
2054
+ }
2055
+ if (text === "/bisect" || text.startsWith("/bisect ")) {
2056
+ this.editor.setText("");
2057
+ await this.handleBisectCommand(text.slice("/bisect".length).trim());
2058
+ return;
2059
+ }
2036
2060
  if (text === "/pin" || text.startsWith("/pin ")) {
2037
2061
  this.editor.setText("");
2038
2062
  this.handlePinCommand(text.startsWith("/pin ") ? text.slice(5).trim() : undefined);
@@ -3031,8 +3055,8 @@ export class InteractiveMode {
3031
3055
  this.ui.requestRender();
3032
3056
  }
3033
3057
  showPackageUpdateNotification(packages) {
3034
- const action = theme.fg("accent", `${APP_NAME} update`);
3035
- const updateInstruction = theme.fg("muted", "Package updates are available. Run ") + action;
3058
+ const action = theme.fg("accent", "/update");
3059
+ const updateInstruction = theme.fg("muted", "Extension updates are available. Type ") + action;
3036
3060
  const packageLines = packages.map((pkg) => `- ${pkg}`).join("\n");
3037
3061
  this.chatContainer.addChild(new Spacer(1));
3038
3062
  this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
@@ -3260,6 +3284,7 @@ export class InteractiveMode {
3260
3284
  smartRouting: this.settingsManager.getSmartRouting(),
3261
3285
  codebaseMemory: this.settingsManager.getCodebaseMemory(),
3262
3286
  autoSkills: this.settingsManager.getAutoSkills(),
3287
+ autoExtensions: this.settingsManager.getAutoExtensions(),
3263
3288
  diffInEditor: this.settingsManager.getDiffInEditor(),
3264
3289
  doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),
3265
3290
  treeFilterMode: this.settingsManager.getTreeFilterMode(),
@@ -3354,6 +3379,9 @@ export class InteractiveMode {
3354
3379
  onAutoSkillsChange: (enabled) => {
3355
3380
  this.settingsManager.setAutoSkills(enabled);
3356
3381
  },
3382
+ onAutoExtensionsChange: (enabled) => {
3383
+ this.settingsManager.setAutoExtensions(enabled);
3384
+ },
3357
3385
  onDiffInEditorChange: (enabled) => {
3358
3386
  this.settingsManager.setDiffInEditor(enabled);
3359
3387
  },
@@ -3528,71 +3556,114 @@ export class InteractiveMode {
3528
3556
  }
3529
3557
  }
3530
3558
  async handleInAppUpdate() {
3531
- // If no update detected yet, check now
3559
+ if (process.env.ELYRA_OFFLINE) {
3560
+ this.showStatus("Updates are disabled in offline mode.");
3561
+ return;
3562
+ }
3563
+ this.chatContainer.addChild(new Spacer(1));
3564
+ const statusText = new Text(theme.fg("dim", "Checking for updates..."), 1, 0);
3565
+ this.chatContainer.addChild(statusText);
3566
+ this.ui.requestRender();
3567
+ // Check for an Elyra version update (populates this._availableUpdate).
3532
3568
  if (!this._availableUpdate) {
3533
3569
  await this.checkForVersionUpdate();
3534
3570
  }
3535
- if (!this._availableUpdate) {
3536
- this.showStatus(`Already on latest version (${this.version})`);
3571
+ const elyraUpdate = this._availableUpdate;
3572
+ // Check for installed-extension updates.
3573
+ const packageManager = new DefaultPackageManager({
3574
+ cwd: this.sessionManager.getCwd(),
3575
+ agentDir: getAgentDir(),
3576
+ settingsManager: this.settingsManager,
3577
+ });
3578
+ let extensionUpdates = [];
3579
+ try {
3580
+ extensionUpdates = await packageManager.checkForAvailableUpdates();
3581
+ }
3582
+ catch {
3583
+ // Best-effort: a failed extension check shouldn't block an Elyra update.
3584
+ }
3585
+ if (!elyraUpdate && extensionUpdates.length === 0) {
3586
+ statusText.setText(theme.fg("dim", `Already up to date (Elyra ${this.version}, all extensions current)`));
3587
+ this.ui.requestRender();
3537
3588
  return;
3538
3589
  }
3539
- const { latest, current } = this._availableUpdate;
3540
- // Get the self-update command for this installation
3541
- const settingsManager = this.settingsManager;
3542
- const npmCommand = settingsManager.getGlobalSettings().npmCommand;
3543
- const selfUpdateCommand = getSelfUpdateCommand(PACKAGE_NAME, npmCommand);
3544
- if (!selfUpdateCommand) {
3545
- this.showError(`Cannot self-update this installation. Run manually:\n ${theme.fg("accent", `npm install -g ${PACKAGE_NAME}`)}`);
3590
+ // If an Elyra update is available, make sure we can actually self-update.
3591
+ let selfUpdateCommand;
3592
+ if (elyraUpdate) {
3593
+ const npmCommand = this.settingsManager.getGlobalSettings().npmCommand;
3594
+ selfUpdateCommand = getSelfUpdateCommand(PACKAGE_NAME, npmCommand);
3595
+ if (!selfUpdateCommand) {
3596
+ statusText.setText(theme.fg("error", `Cannot self-update this installation. Update Elyra manually: npm install -g ${PACKAGE_NAME}`));
3597
+ this.ui.requestRender();
3598
+ // We can still update extensions below.
3599
+ }
3600
+ }
3601
+ // Nothing actionable: Elyra can't self-update and there are no extension updates.
3602
+ if (extensionUpdates.length === 0 && !(elyraUpdate && selfUpdateCommand)) {
3546
3603
  return;
3547
3604
  }
3548
- // Show progress
3549
- this.chatContainer.addChild(new Spacer(1));
3550
- const updateText = new Text(theme.fg("accent", `Updating ${current} \u2192 ${latest}...`), 1, 0);
3551
- this.chatContainer.addChild(updateText);
3605
+ // Announce what is being updated.
3606
+ const parts = [];
3607
+ if (elyraUpdate && selfUpdateCommand)
3608
+ parts.push(`Elyra ${elyraUpdate.current} \u2192 ${elyraUpdate.latest}`);
3609
+ if (extensionUpdates.length > 0) {
3610
+ parts.push(`${extensionUpdates.length} extension${extensionUpdates.length === 1 ? "" : "s"}`);
3611
+ }
3612
+ statusText.setText(theme.fg("accent", `Updating ${parts.join(" and ")}...`));
3552
3613
  this.ui.requestRender();
3553
- try {
3554
- // Run each step of the update command
3555
- for (const step of selfUpdateCommand.steps ?? [selfUpdateCommand]) {
3556
- await new Promise((resolve, reject) => {
3557
- const child = spawn(step.command, step.args, {
3558
- stdio: ["ignore", "pipe", "pipe"],
3559
- shell: process.platform === "win32",
3560
- });
3561
- let stderr = "";
3562
- child.stderr?.on("data", (data) => {
3563
- stderr += data.toString();
3564
- });
3565
- child.on("close", (code) => {
3566
- if (code === 0)
3567
- resolve();
3568
- else
3569
- reject(new Error(stderr.trim() || `Exit code ${code}`));
3570
- });
3571
- child.on("error", reject);
3572
- });
3614
+ // 1. Update extensions while Elyra is still running.
3615
+ if (extensionUpdates.length > 0) {
3616
+ try {
3617
+ await packageManager.update();
3618
+ }
3619
+ catch (error) {
3620
+ const msg = error instanceof Error ? error.message : String(error);
3621
+ this.showError(`Extension update failed: ${msg}`);
3573
3622
  }
3574
- // Success — show message and restart
3575
- updateText.setText(theme.fg("accent", `Updated to ${latest}. Restarting...`));
3576
- this.ui.requestRender();
3577
- // Brief pause so the user sees the message
3578
- await new Promise((r) => setTimeout(r, 500));
3579
- // Restart: stop TUI, then exec the same command
3580
- this.stop();
3581
- await this.runtimeHost.dispose();
3582
- // Re-exec elyra with the same arguments
3583
- const args = process.argv.slice(1);
3584
- const child = spawn(process.argv[0], args, {
3585
- stdio: "inherit",
3586
- shell: false,
3587
- });
3588
- child.on("close", (code) => {
3589
- process.exit(code ?? 0);
3590
- });
3591
3623
  }
3592
- catch (error) {
3593
- const msg = error instanceof Error ? error.message : String(error);
3594
- this.showError(`Update failed: ${msg}\n\nRun manually: ${selfUpdateCommand.display}`);
3624
+ // 2. Self-update Elyra if a newer version is available and updatable.
3625
+ if (elyraUpdate && selfUpdateCommand) {
3626
+ try {
3627
+ for (const step of selfUpdateCommand.steps ?? [selfUpdateCommand]) {
3628
+ await new Promise((resolve, reject) => {
3629
+ const child = spawn(step.command, step.args, {
3630
+ stdio: ["ignore", "pipe", "pipe"],
3631
+ shell: process.platform === "win32",
3632
+ });
3633
+ let stderr = "";
3634
+ child.stderr?.on("data", (data) => {
3635
+ stderr += data.toString();
3636
+ });
3637
+ child.on("close", (code) => {
3638
+ if (code === 0)
3639
+ resolve();
3640
+ else
3641
+ reject(new Error(stderr.trim() || `Exit code ${code}`));
3642
+ });
3643
+ child.on("error", reject);
3644
+ });
3645
+ }
3646
+ }
3647
+ catch (error) {
3648
+ const msg = error instanceof Error ? error.message : String(error);
3649
+ this.showError(`Elyra update failed: ${msg}\n\nRun manually: ${selfUpdateCommand.display}`);
3650
+ return;
3651
+ }
3595
3652
  }
3653
+ // 3. Restart so the new Elyra and/or updated extensions load fresh.
3654
+ statusText.setText(theme.fg("accent", "Updated. Restarting..."));
3655
+ this.ui.requestRender();
3656
+ await new Promise((r) => setTimeout(r, 500));
3657
+ this.stop();
3658
+ await this.runtimeHost.dispose();
3659
+ const args = process.argv.slice(1);
3660
+ const child = spawn(process.argv[0], args, {
3661
+ stdio: "inherit",
3662
+ shell: false,
3663
+ });
3664
+ child.on("close", (code) => {
3665
+ process.exit(code ?? 0);
3666
+ });
3596
3667
  }
3597
3668
  handleGoalCommand(args) {
3598
3669
  // No args = show or clear goal
@@ -3771,6 +3842,8 @@ export class InteractiveMode {
3771
3842
  ` ${theme.fg("muted", "/theme")} Switch theme`,
3772
3843
  ` ${theme.fg("muted", "/cost")} Token usage and cost`,
3773
3844
  ` ${theme.fg("muted", "/diff")} Show changes (--session, --cached, or <path>)`,
3845
+ ` ${theme.fg("muted", "/review")} Get a second-opinion code review of your changes`,
3846
+ ` ${theme.fg("muted", "/bisect <cmd>")} Find which turn broke things (git bisect over checkpoints)`,
3774
3847
  ` ${theme.fg("muted", "/pin <path>")} Pin file to context`,
3775
3848
  ` ${theme.fg("muted", "/memory")} View project memory`,
3776
3849
  ` ${theme.fg("muted", "/blueprint")} Apply session template`,
@@ -3904,6 +3977,247 @@ export class InteractiveMode {
3904
3977
  this.showError("Failed to run git diff.");
3905
3978
  }
3906
3979
  }
3980
+ /**
3981
+ * /review — ask a model for a fresh, independent critique of the current
3982
+ * changes. By default reviews uncommitted changes; --session scopes to what
3983
+ * the agent changed this session. An optional path filters the diff.
3984
+ */
3985
+ async handleReviewCommand(args) {
3986
+ const cwd = this.sessionManager.getCwd();
3987
+ const tokens = args.split(/\s+/).filter(Boolean);
3988
+ let sessionMode = false;
3989
+ const pathArgs = [];
3990
+ for (const token of tokens) {
3991
+ if (token === "--session" || token === "-s")
3992
+ sessionMode = true;
3993
+ else
3994
+ pathArgs.push(token);
3995
+ }
3996
+ const runGit = (gitArgs) => {
3997
+ const result = spawnSync("git", gitArgs, {
3998
+ cwd,
3999
+ encoding: "utf-8",
4000
+ maxBuffer: 32 * 1024 * 1024,
4001
+ stdio: ["ignore", "pipe", "pipe"],
4002
+ });
4003
+ return result.stdout ?? "";
4004
+ };
4005
+ const inside = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
4006
+ cwd,
4007
+ encoding: "utf-8",
4008
+ stdio: ["ignore", "pipe", "pipe"],
4009
+ });
4010
+ if (inside.status !== 0) {
4011
+ this.showError("Not a git repository.");
4012
+ return;
4013
+ }
4014
+ const pathSpec = pathArgs.length > 0 ? ["--", ...pathArgs] : [];
4015
+ let diff;
4016
+ if (sessionMode) {
4017
+ const baseline = this.session.getSessionBaselineRef();
4018
+ if (!baseline) {
4019
+ this.showStatus("No session changes to review yet (the agent hasn't modified files this session).");
4020
+ return;
4021
+ }
4022
+ diff = runGit(["diff", baseline, ...pathSpec]);
4023
+ }
4024
+ else {
4025
+ diff = runGit(["diff", "HEAD", ...pathSpec]);
4026
+ if (!diff.trim())
4027
+ diff = runGit(["diff", ...pathSpec]);
4028
+ }
4029
+ if (!diff.trim()) {
4030
+ this.showStatus("No changes to review.");
4031
+ return;
4032
+ }
4033
+ const model = this.session.model;
4034
+ if (!model) {
4035
+ this.showError("No model selected. Use /model to choose one first.");
4036
+ return;
4037
+ }
4038
+ // Cap very large diffs so the review request stays within limits.
4039
+ const MAX_DIFF_CHARS = 60_000;
4040
+ const truncated = diff.length > MAX_DIFF_CHARS;
4041
+ const diffForReview = truncated ? `${diff.slice(0, MAX_DIFF_CHARS)}\n... (diff truncated)` : diff;
4042
+ const scopeLabel = sessionMode ? "this session's changes" : "uncommitted changes";
4043
+ this.chatContainer.addChild(new Spacer(1));
4044
+ this.chatContainer.addChild(new DynamicBorder());
4045
+ this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", `Code Review \u2014 ${scopeLabel}`)), 1, 0));
4046
+ const loader = new BorderedLoader(this.ui, theme, `Reviewing with ${model.name}...`, { cancellable: false });
4047
+ this.chatContainer.addChild(loader);
4048
+ this.ui.requestRender();
4049
+ const finishLoader = () => {
4050
+ loader.dispose();
4051
+ this.chatContainer.removeChild(loader);
4052
+ };
4053
+ try {
4054
+ const apiKey = await this.session.modelRegistry.getApiKeyForProvider(model.provider);
4055
+ const context = {
4056
+ systemPrompt: REVIEW_SYSTEM_PROMPT,
4057
+ messages: [
4058
+ {
4059
+ role: "user",
4060
+ content: `Review the following diff and report your findings.\n\n\`\`\`diff\n${diffForReview}\n\`\`\``,
4061
+ timestamp: Date.now(),
4062
+ },
4063
+ ],
4064
+ };
4065
+ const result = await completeSimple(model, context, apiKey ? { apiKey } : undefined);
4066
+ const reviewText = result.content
4067
+ .filter((c) => c.type === "text")
4068
+ .map((c) => c.text)
4069
+ .join("\n")
4070
+ .trim();
4071
+ finishLoader();
4072
+ if (!reviewText) {
4073
+ this.chatContainer.addChild(new Text(theme.fg("muted", "The reviewer returned no findings."), 1, 0));
4074
+ }
4075
+ else {
4076
+ this.chatContainer.addChild(new Markdown(reviewText, 1, 1, this.getMarkdownThemeWithSettings()));
4077
+ if (truncated) {
4078
+ this.chatContainer.addChild(new Text(theme.fg("muted", "(Diff was large and truncated for review.)"), 1, 0));
4079
+ }
4080
+ }
4081
+ this.chatContainer.addChild(new DynamicBorder());
4082
+ this.ui.requestRender();
4083
+ }
4084
+ catch (error) {
4085
+ finishLoader();
4086
+ const msg = error instanceof Error ? error.message : String(error);
4087
+ this.chatContainer.addChild(new Text(theme.fg("error", `Review failed: ${msg}`), 1, 0));
4088
+ this.chatContainer.addChild(new DynamicBorder());
4089
+ this.ui.requestRender();
4090
+ }
4091
+ }
4092
+ /**
4093
+ * /bisect — binary-search the session's checkpoints to find which agent turn
4094
+ * introduced a problem, like `git bisect` but over the agent's own steps.
4095
+ *
4096
+ * With a command argument it runs automatically (exit 0 = good, non-zero =
4097
+ * bad), like `git bisect run`. Without one, it asks you to mark each step
4098
+ * good or bad after testing manually. The working tree is always restored at
4099
+ * the end.
4100
+ */
4101
+ async handleBisectCommand(command) {
4102
+ const points = this.session.getRewindPoints();
4103
+ if (points.length < 2) {
4104
+ this.showStatus("Not enough checkpoints to bisect. Checkpoints are created as the agent runs.");
4105
+ return;
4106
+ }
4107
+ const snapshot = this.session.createWorkingTreeSnapshot();
4108
+ if (!snapshot) {
4109
+ this.showError("Not a git repository — /bisect needs git checkpoints.");
4110
+ return;
4111
+ }
4112
+ const cwd = this.sessionManager.getCwd();
4113
+ const describe = (i) => `${i + 1}/${points.length}: ${points[i].text.replace(/\n/g, " ").slice(0, 60)}`;
4114
+ const runCommand = () => new Promise((resolve) => {
4115
+ const child = spawn("sh", ["-c", command], {
4116
+ cwd,
4117
+ stdio: ["ignore", "pipe", "pipe"],
4118
+ timeout: 120_000,
4119
+ });
4120
+ child.on("close", (code) => resolve(code ?? 1));
4121
+ child.on("error", () => resolve(1));
4122
+ });
4123
+ this.chatContainer.addChild(new Spacer(1));
4124
+ this.chatContainer.addChild(new DynamicBorder());
4125
+ const title = command ? `Bisecting with: ${command}` : "Bisecting (test each checkpoint manually)";
4126
+ this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", title)), 1, 0));
4127
+ const status = new Text(theme.fg("muted", "Preparing..."), 1, 0);
4128
+ this.chatContainer.addChild(status);
4129
+ this.ui.requestRender();
4130
+ // Ask whether a step is good. Returns true=good, false=bad, undefined=abort.
4131
+ const askGoodBad = async (index) => {
4132
+ if (command) {
4133
+ status.setText(theme.fg("muted", `Testing checkpoint ${describe(index)}...`));
4134
+ this.ui.requestRender();
4135
+ await new Promise((r) => setTimeout(r, 16));
4136
+ const code = await runCommand();
4137
+ return code === 0;
4138
+ }
4139
+ const choice = await this.showExtensionSelector(`Restored files to checkpoint ${describe(index)}.\nTest now (in another terminal), then choose:`, ["Good (works here)", "Bad (broken here)", "Abort"]);
4140
+ if (!choice || choice === "Abort")
4141
+ return undefined;
4142
+ return choice.startsWith("Good");
4143
+ };
4144
+ let aborted = false;
4145
+ let culpritIndex;
4146
+ try {
4147
+ // In automated mode, verify the current state is actually bad first.
4148
+ if (command) {
4149
+ status.setText(theme.fg("muted", "Checking current state..."));
4150
+ this.ui.requestRender();
4151
+ await new Promise((r) => setTimeout(r, 16));
4152
+ if ((await runCommand()) === 0) {
4153
+ status.setText(theme.fg("muted", "Current state passes the command — nothing to bisect."));
4154
+ this.chatContainer.addChild(new DynamicBorder());
4155
+ this.ui.requestRender();
4156
+ return;
4157
+ }
4158
+ }
4159
+ // lo = known good, hi = known bad. Find the first bad checkpoint in (lo, hi].
4160
+ let lo = 0;
4161
+ let hi = points.length - 1;
4162
+ // Establish that the earliest checkpoint is good.
4163
+ const earliestRestore = this.session.restoreFilesToCheckpoint(points[0].entryId);
4164
+ if (!earliestRestore.ok) {
4165
+ this.showError(`Bisect failed to restore checkpoint: ${earliestRestore.error}`);
4166
+ aborted = true;
4167
+ }
4168
+ else {
4169
+ const earliestGood = await askGoodBad(0);
4170
+ if (earliestGood === undefined) {
4171
+ aborted = true;
4172
+ }
4173
+ else if (!earliestGood) {
4174
+ status.setText(theme.fg("muted", "The earliest checkpoint is already bad — the cause predates this session."));
4175
+ this.chatContainer.addChild(new DynamicBorder());
4176
+ this.ui.requestRender();
4177
+ aborted = true;
4178
+ }
4179
+ }
4180
+ while (!aborted && hi - lo > 1) {
4181
+ const mid = Math.floor((lo + hi) / 2);
4182
+ const restore = this.session.restoreFilesToCheckpoint(points[mid].entryId);
4183
+ if (!restore.ok) {
4184
+ this.showError(`Bisect failed to restore checkpoint: ${restore.error}`);
4185
+ aborted = true;
4186
+ break;
4187
+ }
4188
+ const good = await askGoodBad(mid);
4189
+ if (good === undefined) {
4190
+ aborted = true;
4191
+ break;
4192
+ }
4193
+ if (good)
4194
+ lo = mid;
4195
+ else
4196
+ hi = mid;
4197
+ }
4198
+ if (!aborted)
4199
+ culpritIndex = hi;
4200
+ }
4201
+ finally {
4202
+ const restored = this.session.restoreFilesToSnapshot(snapshot);
4203
+ if (!restored.ok) {
4204
+ this.showError(`Bisect could not restore your working tree: ${restored.error}`);
4205
+ }
4206
+ }
4207
+ if (aborted) {
4208
+ status.setText(theme.fg("muted", "Bisect stopped. Your working tree was restored."));
4209
+ this.chatContainer.addChild(new DynamicBorder());
4210
+ this.ui.requestRender();
4211
+ return;
4212
+ }
4213
+ if (culpritIndex !== undefined) {
4214
+ const culprit = points[culpritIndex];
4215
+ status.setText(`${theme.fg("success", "First bad checkpoint:")} ${theme.fg("accent", culprit.text.replace(/\n/g, " ").slice(0, 80))}`);
4216
+ this.chatContainer.addChild(new Text(theme.fg("muted", "The change introduced at this turn is the likely culprit. Use /rewind to return here."), 1, 0));
4217
+ this.chatContainer.addChild(new DynamicBorder());
4218
+ this.ui.requestRender();
4219
+ }
4220
+ }
3907
4221
  /**
3908
4222
  * Open read-only text content in the user's external editor ($VISUAL/$EDITOR).
3909
4223
  * Suspends the TUI while the editor runs, then restores it. The `suffix`
@@ -4973,6 +5287,7 @@ export class InteractiveMode {
4973
5287
  "@elyracode/laravel -- Deep Laravel understanding: models, routes, architecture analysis",
4974
5288
  "@elyracode/btw -- Side conversations: ask questions in parallel without affecting the main session",
4975
5289
  "@elyracode/youtrack -- YouTrack: issues, comments, tags, links, projects, time tracking",
5290
+ "@elyracode/semantic-index -- Semantic code search: find code by meaning via local embeddings",
4976
5291
  ];
4977
5292
  this.showSelector((done) => {
4978
5293
  const selector = new ExtensionSelectorComponent("Install Extension", packages, (selected) => {