@elyracode/coding-agent 0.9.2 → 0.9.4

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 (41) hide show
  1. package/CHANGELOG.md +15 -3
  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 +42 -0
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/settings-manager.d.ts +3 -0
  7. package/dist/core/settings-manager.d.ts.map +1 -1
  8. package/dist/core/settings-manager.js +8 -0
  9. package/dist/core/settings-manager.js.map +1 -1
  10. package/dist/core/slash-commands.d.ts.map +1 -1
  11. package/dist/core/slash-commands.js +8 -0
  12. package/dist/core/slash-commands.js.map +1 -1
  13. package/dist/core/system-prompt.d.ts.map +1 -1
  14. package/dist/core/system-prompt.js +3 -0
  15. package/dist/core/system-prompt.js.map +1 -1
  16. package/dist/core/tools/extension-write.d.ts +46 -0
  17. package/dist/core/tools/extension-write.d.ts.map +1 -0
  18. package/dist/core/tools/extension-write.js +227 -0
  19. package/dist/core/tools/extension-write.js.map +1 -0
  20. package/dist/modes/interactive/components/earendil-announcement.js +1 -1
  21. package/dist/modes/interactive/components/earendil-announcement.js.map +1 -1
  22. package/dist/modes/interactive/components/settings-selector.d.ts +2 -0
  23. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  24. package/dist/modes/interactive/components/settings-selector.js +10 -0
  25. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  26. package/dist/modes/interactive/interactive-mode.d.ts +16 -0
  27. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  28. package/dist/modes/interactive/interactive-mode.js +273 -1
  29. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  30. package/dist/utils/clipboard-native.js +1 -1
  31. package/dist/utils/clipboard-native.js.map +1 -1
  32. package/docs/skills.md +1 -1
  33. package/docs/usage.md +0 -2
  34. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  35. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  36. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  37. package/examples/extensions/sandbox/package-lock.json +2 -2
  38. package/examples/extensions/sandbox/package.json +1 -1
  39. package/examples/extensions/with-deps/package-lock.json +2 -2
  40. package/examples/extensions/with-deps/package.json +1 -1
  41. package/package.json +5 -5
@@ -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);
@@ -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
  },
@@ -3814,6 +3842,8 @@ export class InteractiveMode {
3814
3842
  ` ${theme.fg("muted", "/theme")} Switch theme`,
3815
3843
  ` ${theme.fg("muted", "/cost")} Token usage and cost`,
3816
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)`,
3817
3847
  ` ${theme.fg("muted", "/pin <path>")} Pin file to context`,
3818
3848
  ` ${theme.fg("muted", "/memory")} View project memory`,
3819
3849
  ` ${theme.fg("muted", "/blueprint")} Apply session template`,
@@ -3947,6 +3977,247 @@ export class InteractiveMode {
3947
3977
  this.showError("Failed to run git diff.");
3948
3978
  }
3949
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
+ }
3950
4221
  /**
3951
4222
  * Open read-only text content in the user's external editor ($VISUAL/$EDITOR).
3952
4223
  * Suspends the TUI while the editor runs, then restores it. The `suffix`
@@ -5016,6 +5287,7 @@ export class InteractiveMode {
5016
5287
  "@elyracode/laravel -- Deep Laravel understanding: models, routes, architecture analysis",
5017
5288
  "@elyracode/btw -- Side conversations: ask questions in parallel without affecting the main session",
5018
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",
5019
5291
  ];
5020
5292
  this.showSelector((done) => {
5021
5293
  const selector = new ExtensionSelectorComponent("Install Extension", packages, (selected) => {