indusagi-coding-agent 0.2.12 → 0.2.14

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 (81) hide show
  1. package/.env.review.example +25 -0
  2. package/README.md +30 -0
  3. package/REVIEW_AGENT.md +137 -0
  4. package/dist/entry.js +1861 -979
  5. package/dist/guardrails.js +212 -36
  6. package/dist/index.js +2829 -1057
  7. package/dist/review-agent.js +4461 -0
  8. package/dist/review-server.js +4633 -0
  9. package/package.json +24 -7
  10. package/src/boot/auth-vault.ts +4 -5
  11. package/src/boot/contract.ts +1 -1
  12. package/src/boot/runners/addon-wiring.ts +111 -3
  13. package/src/boot/runners/benchmark-integrity.ts +107 -0
  14. package/src/boot/runners/repl-runner.ts +2 -1
  15. package/src/boot/runners/session.ts +26 -12
  16. package/src/boot/server-token.ts +20 -11
  17. package/src/boot/stages.ts +3 -6
  18. package/src/briefing/context-docs.ts +144 -12
  19. package/src/channels/oneshot.ts +6 -0
  20. package/src/conductor/catalog/catalog.ts +3 -1
  21. package/src/conductor/conductor.ts +77 -17
  22. package/src/conductor/contract.ts +9 -7
  23. package/src/conductor/index.ts +1 -1
  24. package/src/conductor/transcript-store/store.ts +4 -8
  25. package/src/console/auth-status.ts +9 -4
  26. package/src/console/components/AgentsView.ts +2 -2
  27. package/src/console/components/BackgroundAgents.ts +15 -2
  28. package/src/console/components/Banner.ts +20 -9
  29. package/src/console/components/Composer.ts +57 -82
  30. package/src/console/components/ScrollViewport.ts +46 -0
  31. package/src/console/components/TerminalConsole.ts +165 -162
  32. package/src/console/components/WorkingIndicator.ts +6 -5
  33. package/src/console/components/banner-sweep.ts +5 -1
  34. package/src/console/components/welcome.ts +3 -3
  35. package/src/console/contract.ts +10 -5
  36. package/src/console/index.ts +7 -7
  37. package/src/console/input/complete.ts +2 -2
  38. package/src/console/input/dir-reader.ts +3 -1
  39. package/src/console/input/graphemes.ts +22 -0
  40. package/src/console/input/keymap.ts +6 -4
  41. package/src/console/mount.ts +45 -3
  42. package/src/console/overlays/auth.ts +28 -8
  43. package/src/console/overlays/boards.ts +13 -40
  44. package/src/console/overlays/host.ts +10 -0
  45. package/src/console/overlays/pickers.ts +35 -30
  46. package/src/console/reducer.ts +12 -5
  47. package/src/console/slash/builtins.ts +4 -3
  48. package/src/console/slash/commands/integrations.ts +13 -14
  49. package/src/console/slash/commands/transcript.ts +3 -8
  50. package/src/console/slash/commands/workbench.ts +4 -4
  51. package/src/console/test-support.ts +48 -0
  52. package/src/index.ts +1 -0
  53. package/src/integrations/sarvam/config.ts +15 -13
  54. package/src/kit/external-editor.ts +17 -1
  55. package/src/kit/run-shell.ts +31 -0
  56. package/src/launch/catalog.ts +4 -2
  57. package/src/launch/contract.ts +1 -0
  58. package/src/launch/invocation/flags.ts +13 -1
  59. package/src/launch/invocation/read.ts +4 -3
  60. package/src/launch/login.ts +23 -10
  61. package/src/launch/oauth.ts +3 -0
  62. package/src/models.ts +166 -0
  63. package/src/review-agent/config.ts +75 -0
  64. package/src/review-agent/contract.ts +59 -0
  65. package/src/review-agent/github.ts +242 -0
  66. package/src/review-agent/index.ts +8 -0
  67. package/src/review-agent/prompt.ts +132 -0
  68. package/src/review-agent/reviewer.ts +118 -0
  69. package/src/review-agent/server.ts +221 -0
  70. package/src/review-agent/service.ts +165 -0
  71. package/src/review-agent/supabase.ts +209 -0
  72. package/src/review-agent/webhook.ts +76 -0
  73. package/src/security/private-state.ts +122 -0
  74. package/src/window-budget/summarize/condense.ts +4 -1
  75. package/src/workspace/brand.ts +1 -1
  76. package/src/workspace/locator.ts +2 -2
  77. package/supabase/migrations/20260827031803_indusagi_review_agent.sql +109 -0
  78. package/supabase/migrations/20260827035747_atomic_webhook_enqueue.sql +75 -0
  79. package/supabase/migrations/20260827040104_check_run_reconciliation.sql +8 -0
  80. package/supabase/migrations/20260827041049_owned_job_leases.sql +80 -0
  81. package/supabase/migrations/20260827042500_static_token_webhooks.sql +75 -0
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import { EMPTY_CONSOLE_STATE, NO_MODAL, transitionModal, type ConsoleEvent, type ConsoleState } from "./contract.js";
13
13
 
14
+ import { previousGrapheme, nextGrapheme, moveGrapheme, snapGrapheme } from "./input/graphemes.js";
15
+
14
16
  function clamp(offset: number, max: number) {
15
17
  if (offset < 0) return 0;
16
18
  if (offset > max) return max;
@@ -38,14 +40,15 @@ function insertBuffer(state: ConsoleState, text: string) {
38
40
  function erasePrev(state: ConsoleState) {
39
41
  const at = clamp(state.caret, state.buffer.length);
40
42
  if (at === 0) return state;
41
- const next = state.buffer.slice(0, at - 1) + state.buffer.slice(at);
42
- return withBuffer(state, next, at - 1);
43
+ const previous = previousGrapheme(state.buffer, at);
44
+ const next = state.buffer.slice(0, previous) + state.buffer.slice(at);
45
+ return withBuffer(state, next, previous);
43
46
  }
44
47
 
45
48
  function eraseNext(state: ConsoleState) {
46
49
  const at = clamp(state.caret, state.buffer.length);
47
50
  if (at >= state.buffer.length) return state;
48
- const next = state.buffer.slice(0, at) + state.buffer.slice(at + 1);
51
+ const next = state.buffer.slice(0, at) + state.buffer.slice(nextGrapheme(state.buffer, at));
49
52
  return withBuffer(state, next, at);
50
53
  }
51
54
 
@@ -97,6 +100,10 @@ function patchRow(state: ConsoleState, id: string, text: string) {
97
100
  */
98
101
  export function consoleReducer(state: ConsoleState, event: ConsoleEvent): ConsoleState {
99
102
  switch (event.type) {
103
+ case "view:reset":
104
+ return { ...state, rows: [], blocks: [], buffer: "", caret: 0, historyAt: null, stash: "", modal: NO_MODAL, busy: false };
105
+ case "display:set":
106
+ return { ...state, [event.key]: event.value };
100
107
  // Composer buffer & caret
101
108
  case "buffer:set":
102
109
  return setBuffer(state, event.text);
@@ -119,9 +126,9 @@ export function consoleReducer(state: ConsoleState, event: ConsoleEvent): Consol
119
126
  case "buffer:eraseNext":
120
127
  return eraseNext(state);
121
128
  case "caret:move":
122
- return { ...state, caret: clamp(state.caret + event.delta, state.buffer.length) };
129
+ return { ...state, caret: moveGrapheme(state.buffer, state.caret, event.delta) };
123
130
  case "caret:jump":
124
- return { ...state, caret: clamp(event.offset, state.buffer.length) };
131
+ return { ...state, caret: snapGrapheme(state.buffer, event.offset) };
125
132
  // History ring
126
133
  case "history:push":
127
134
  return pushHistory(state, event.text);
@@ -4,6 +4,7 @@
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { gatherSkillCards, loadMacros } from "../../briefing/index.js";
7
+ import type { SkillCard, Macro } from "../../briefing/index.js";
7
8
  import type { SlashCommand } from "../contract.js";
8
9
  import { FAMILY } from "./commands/shared.js";
9
10
  import { buildDynamicCommands } from "./commands/dynamic.js";
@@ -30,16 +31,16 @@ function dynamicRoots() {
30
31
 
31
32
  function discoverDynamicSources() {
32
33
  const roots = dynamicRoots();
33
- let skills: Array<{ name: string; location: string }> = [];
34
+ let skills: SkillCard[] = [];
34
35
  try {
35
36
  skills = gatherSkillCards(roots.skills).cards.slice();
36
37
  } catch {
37
38
  skills = [];
38
39
  }
39
- const templates: Array<{ name: string; source: string }> = [];
40
+ const templates: Macro[] = [];
40
41
  const claimed = new Set<string>();
41
42
  for (const root of roots.macros) {
42
- let loaded: Array<{ name: string; source: string }> = [];
43
+ let loaded: Macro[] = [];
43
44
  try {
44
45
  loaded = loadMacros(root.dir, { origin: root.origin, label: root.label });
45
46
  } catch {
@@ -3,6 +3,7 @@
3
3
  * clipboard / export / share I/O verbs.
4
4
  */
5
5
  import { spawnSync } from "node:child_process";
6
+ import { getEnvApiKey } from "indusagi/ai";
6
7
  import { readFileSync, writeFileSync } from "node:fs";
7
8
  import { homedir } from "node:os";
8
9
  import { join, resolve } from "node:path";
@@ -10,6 +11,7 @@ import { createMCPAgentToolFactory, loadMCPConfig, MCPClientPool } from "indusag
10
11
  import { createComposioGateway } from "indusagi/connectors-saas";
11
12
  import type { SlashCommand, SlashContext } from "../../contract.js";
12
13
  import { FAMILY, HANDLED, familyRunner, info, warn, splitVerb } from "./shared.js";
14
+ import type { TranscriptEntry } from "../../../transcript-export/publish.js";
13
15
  import { publishTranscript } from "../../../transcript-export/index.js";
14
16
  import { APP_NOVEL_CARDS, memoryCard } from "../../../capability-deck/index.js";
15
17
  import { getServerToken, hasValidServerToken, INDUS_SERVER_URL } from "../../../boot/server-token.js";
@@ -48,7 +50,7 @@ function writeClipboard(text: string): boolean {
48
50
  return false;
49
51
  }
50
52
 
51
- function lastAssistantText(messages: any[]): string {
53
+ function lastAssistantText(messages: readonly any[]): string {
52
54
  for (let i = messages.length - 1; i >= 0; i--) {
53
55
  const message = messages[i];
54
56
  if (message.role !== "assistant") continue;
@@ -66,11 +68,12 @@ function lastAssistantText(messages: any[]): string {
66
68
  return "";
67
69
  }
68
70
 
69
- function toPublishRole(role: string): string {
70
- return PUBLISH_ROLES.has(role) ? role : "note";
71
+ function toPublishRole(role: string): TranscriptEntry["role"] {
72
+ if (role === "toolResult") return "tool";
73
+ return PUBLISH_ROLES.has(role) ? role as TranscriptEntry["role"] : "note";
71
74
  }
72
75
 
73
- function toPublishEntries(messages: any[]) {
76
+ function toPublishEntries(messages: readonly any[]) {
74
77
  return messages.map((message) => {
75
78
  const m = message;
76
79
  return {
@@ -541,16 +544,12 @@ function runLogin(ctx: SlashContext) {
541
544
  return HANDLED;
542
545
  }
543
546
 
544
- function hasLocalProviderKey(provider: string) {
545
- if (provider.length === 0) return false;
547
+ async function hasLocalProviderKey(provider: string, ctx: SlashContext) {
548
+ if (!provider) return false;
546
549
  try {
547
- const file = join(homedir(), ".indusagi", "agent", "auth.json");
548
- const vault = JSON.parse(readFileSync(file, "utf8"));
549
- const entry = vault[provider];
550
- return entry !== null && typeof entry === "object" && Object.keys(entry).length > 0;
551
- } catch {
552
- return false;
553
- }
550
+ if (getEnvApiKey(provider)) return true;
551
+ return (await ctx.services?.vault.listAccounts(provider) ?? []).length > 0;
552
+ } catch { return false; }
554
553
  }
555
554
 
556
555
  function runStatus(ctx: SlashContext) {
@@ -564,7 +563,7 @@ function runStatus(ctx: SlashContext) {
564
563
  signedIn = false;
565
564
  }
566
565
  const eligible = isGatewayEligibleProvider(provider);
567
- const localKey = hasLocalProviderKey(provider);
566
+ const localKey = await hasLocalProviderKey(provider, ctx);
568
567
  const viaServer = signedIn && eligible && !localKey;
569
568
  if (viaServer) {
570
569
  ctx.setStatus(info(`● server tier — ${modelId} routing via ${INDUS_SERVER_URL} (signed in)`));
@@ -69,12 +69,7 @@ const compactCommand: SlashCommand = {
69
69
  ctx.setStatus({ kind: "busy", text: busyText });
70
70
  void (async () => {
71
71
  try {
72
- if (instructions.length > 0) {
73
- await ctx.conductor.executeBash(
74
- `printf '%s' ${JSON.stringify(`Compaction guidance: ${instructions}`)}`
75
- );
76
- }
77
- switch (await ctx.conductor.condense()) {
72
+ switch (await ctx.conductor.condense(instructions || undefined)) {
78
73
  case "condensed":
79
74
  ctx.setStatus(
80
75
  info(
@@ -123,7 +118,7 @@ const sessionCommand: SlashCommand = {
123
118
  const name = ctx.conductor.sessionName();
124
119
  const block = {
125
120
  id: `session-stats-${Date.now().toString(36)}`,
126
- kind: "changelog",
121
+ kind: "changelog" as const,
127
122
  title: name ? `Session: ${name}` : "Session statistics",
128
123
  markdown: statsMarkdown(stats, name),
129
124
  timestamp: Date.now()
@@ -155,7 +150,7 @@ const costCommand: SlashCommand = {
155
150
  ${rows.map(([k, v]) => `| ${k} | ${v} |`).join("\n")}`;
156
151
  const block = {
157
152
  id: `session-cost-${Date.now().toString(36)}`,
158
- kind: "changelog",
153
+ kind: "changelog" as const,
159
154
  title: "Session cost",
160
155
  markdown,
161
156
  timestamp: Date.now()
@@ -11,7 +11,7 @@ import { FAMILY, HANDLED, familyRunner, info, warn } from "./shared.js";
11
11
  function block(slug: string, title: string, markdown: string) {
12
12
  return {
13
13
  id: `${slug}-${Date.now().toString(36)}`,
14
- kind: "changelog",
14
+ kind: "changelog" as const,
15
15
  title,
16
16
  markdown,
17
17
  timestamp: Date.now()
@@ -147,7 +147,7 @@ const HOTKEYS: Array<[string, string]> = [
147
147
  ["Tab", "Accept the highlighted completion"],
148
148
  ["Ctrl+C", "Interrupt the running turn"],
149
149
  ["Ctrl+T", "Toggle reasoning / thinking rows"],
150
- ["/thinking", "Cycle thinking effort level (off → low → medium)"],
150
+ ["/thinking", "Cycle the selected model's supported reasoning efforts"],
151
151
  ["Ctrl+V", "Attach an image from the clipboard"],
152
152
  ["Ctrl+G", "Edit the composer in your external editor"],
153
153
  ["Ctrl+O", "Expand or collapse full tool output"],
@@ -222,7 +222,7 @@ export const workbenchCommands: SlashCommand[] = [
222
222
  {
223
223
  name: "whats-new",
224
224
  summary: "Show what changed in this build",
225
- aliases: ["changelog"],
225
+ aliases: ["changelog", "release-notes"],
226
226
  run: runChangelog
227
227
  },
228
228
  {
@@ -232,7 +232,7 @@ export const workbenchCommands: SlashCommand[] = [
232
232
  },
233
233
  {
234
234
  name: "thinking",
235
- summary: "Cycle thinking effort level (off → low → medium → off)",
235
+ summary: "Cycle reasoning effort using the selected model's supported levels",
236
236
  run: (ctx) => {
237
237
  ctx.conductor.cycleThinkingLevel();
238
238
  return HANDLED;
@@ -0,0 +1,48 @@
1
+ import { Writable, PassThrough } from 'node:stream';
2
+ import React from 'indusagi/react-host';
3
+ import { render } from 'indusagi/react-host/ink';
4
+ import stripAnsi from 'strip-ansi';
5
+ import { TerminalConsole } from './components/TerminalConsole.js';
6
+ import { resolveTheme } from './theme/resolve.js';
7
+ import { DEFAULT_SLASH_REGISTRY } from './slash/index.js';
8
+ import { DEFAULT_PREFERENCES } from '../settings/contract.js';
9
+ export const settle = (ms=55) => new Promise<void>(r=>setTimeout(r,ms));
10
+ export function fixtureConductor() {
11
+ let handler=(signal:any)=>{}, phase='idle', messages:any[]=[], queue:any[]=[], sessionId='fixture';
12
+ const model={id:'test-model',provider:'fixture',contextWindow:200000,name:'Fixture',reasoning:true};
13
+ const submissions:string[]=[];
14
+ const c:any={snapshot:()=>({phase,contextTokens:0,head:{sessionId,leaf:null},modelId:'fixture/test-model'}),model:()=>model,
15
+ stats:()=>({sessionId,userMessages:0,assistantMessages:0,toolCalls:0,toolResults:0,totalMessages:messages.length,tokens:{input:0,output:0,cacheRead:0,cacheWrite:0,total:0},cost:0}),
16
+ pendingCount:()=>queue.length,pendingInputs:()=>queue,thinkingLevel:()=> 'off',sessionName:()=>undefined,availableModels:()=>[model],messages:()=>messages,isBusy:()=>phase!=='idle',
17
+ permissionMode:()=> 'default',setApprovalResolver:(r:any)=>{c.approvalResolver=r},subscribe:(fn:any)=>{handler=fn;return()=>{handler=()=>{}}},
18
+ submit:async(text:string)=>{submissions.push(text);if(phase==='streaming'){queue.push({text,mode:'followUp'});handler({kind:'queue',count:queue.length})}else{phase='streaming'}},
19
+ emit:(sig:any)=>handler(sig),setMessages:(m:any[])=>{messages=m},setPhase:(p:string)=>{phase=p},submissions,
20
+ abort:()=>{c.aborted=(c.aborted??0)+1;phase='idle';handler({kind:'idle'})},
21
+ cyclePermissionMode:()=> 'acceptEdits',cycleThinkingLevel:()=> 'low',dequeueLast:()=>queue.pop()?.text,steerNow:()=>false,
22
+ newSession:async()=>{messages=[];phase='idle';sessionId+='-new';handler({kind:'idle'})},executeBash:async()=>({exitCode:0,output:'SHELL_SENTINEL'})};
23
+ return c;
24
+ }
25
+ export function fixtureServices(c:any, overrides:any={}) {
26
+ const values:any={...DEFAULT_PREFERENCES,quietStartup:true,lastSeenVersion:'0.2.14',defaultProvider:'fixture',...overrides};
27
+ return {conductor:c,settings:{get:(k:string)=>values[k],set:(k:string,v:any)=>{values[k]=v},save:()=>{}},vault:{defaultAccount:async()=>undefined,listAccounts:async()=>['fixture-account']},listLoginProviders:()=>[{id:'fixture',label:'Fixture',authKind:'apiKey'}],values};
28
+ }
29
+ export async function renderTest(element:any,columns=80,rows=24) {
30
+ const frames:string[]=[];
31
+ const stdout=new Writable({write(chunk,_enc,cb){frames.push(String(chunk));cb()}});
32
+ Object.assign(stdout,{columns,rows,isTTY:true});
33
+ const stdin=new PassThrough();Object.assign(stdin,{isTTY:true,setRawMode:()=>{},ref:()=>{},unref:()=>{}});
34
+ const app=render(element,{stdout:stdout as unknown as NodeJS.WriteStream,stdin:stdin as unknown as NodeJS.ReadStream,stderr:stdout as unknown as NodeJS.WriteStream,debug:true,patchConsole:false,exitOnCtrlC:false});
35
+ let exited=false;void app.waitUntilExit().then(()=>{exited=true});
36
+ await settle(80);
37
+ return {app,frames,stdout,stdin,exited:()=>exited,
38
+ last:()=>stripAnsi(frames.filter(f=>f.length>10).at(-1)||''),raw:()=>frames.filter(f=>f.length>10).at(-1)||'',
39
+ key:async(text:string)=>{stdin.write(text);await settle()},
40
+ resize:async(cols:number,r:number)=>{Object.assign(stdout,{columns:cols,rows:r});stdout.emit('resize');await settle()},
41
+ close:()=>{app.unmount();app.cleanup();stdin.destroy();stdout.destroy()}};
42
+ }
43
+ export async function consoleTest(options:any={}) {
44
+ const c=options.conductor??fixtureConductor();
45
+ const svc=options.services===false?undefined:(options.services??fixtureServices(c,options.settings));
46
+ const view=await renderTest(React.createElement(TerminalConsole,{conductor:c,theme:resolveTheme('saffron'),slash:DEFAULT_SLASH_REGISTRY,services:svc,...options.props}),options.columns??80,options.rows??24);
47
+ return {...view,c,svc};
48
+ }
package/src/index.ts CHANGED
@@ -15,4 +15,5 @@ export * as addons from "./addons/index.js";
15
15
  export * as channels from "./channels/index.js";
16
16
  export * as runtimeBridge from "./runtime-bridge/index.js";
17
17
  export * as insight from "./insight/index.js";
18
+ export * as reviewAgent from "./review-agent/index.js";
18
19
  export { VERSION } from "./workspace/index.js";
@@ -12,20 +12,22 @@
12
12
  * prefer env / CLI to avoid committing secrets.
13
13
  */
14
14
 
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
15
+ import { existsSync, readFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
- import { dirname, join } from "node:path";
17
+ import { join } from "node:path";
18
18
  import type { SarvamHostConfig } from "./types.js";
19
+ import { atomicWritePrivateFileSync, readPrivateFileSync } from "../../security/private-state.js";
19
20
 
20
21
  const ENV_KEY = "SARVAM_API_KEY";
21
22
  const ENV_DISABLE = "SARVAM_DISABLED";
22
23
  const ENV_BASE_URL = "SARVAM_API_BASE_URL";
23
24
  const ENV_BASE_PATH = "SARVAM_MCP_BASE_PATH";
24
25
 
25
- function readJsonFile(path: string): Record<string, unknown> | undefined {
26
+ function readJsonFile(path: string, sensitive = false): Record<string, unknown> | undefined {
26
27
  try {
27
- if (!existsSync(path)) return undefined;
28
- const raw = readFileSync(path, "utf8");
28
+ if (!sensitive && !existsSync(path)) return undefined;
29
+ const raw = sensitive ? readPrivateFileSync(path) : readFileSync(path, "utf8");
30
+ if (raw === null) return undefined;
29
31
  const parsed = JSON.parse(raw) as unknown;
30
32
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
31
33
  return undefined;
@@ -36,11 +38,11 @@ function readJsonFile(path: string): Record<string, unknown> | undefined {
36
38
  }
37
39
  }
38
40
 
39
- function configFromRecord(rec: Record<string, unknown> | undefined): SarvamHostConfig | undefined {
41
+ function configFromRecord(rec: Record<string, unknown> | undefined, includeApiKey = true): SarvamHostConfig | undefined {
40
42
  if (!rec) return undefined;
41
43
  const enabled = rec.enabled === false ? false : true;
42
44
  const id = typeof rec.id === "string" && rec.id.trim() ? rec.id.trim() : "sarvam";
43
- const apiKey = typeof rec.apiKey === "string" ? rec.apiKey.trim() : undefined;
45
+ const apiKey = includeApiKey && typeof rec.apiKey === "string" ? rec.apiKey.trim() : undefined;
44
46
  const baseUrl = typeof rec.baseUrl === "string" ? rec.baseUrl.trim() : undefined;
45
47
  const basePath = typeof rec.basePath === "string" ? rec.basePath.trim() : undefined;
46
48
  const command = typeof rec.command === "string" ? rec.command.trim() : undefined;
@@ -73,9 +75,9 @@ export function saveSarvamConfig(
73
75
  config: SarvamHostConfig,
74
76
  where: "profile" | "project" = "profile",
75
77
  cwd = process.cwd(),
78
+ home = homedir(),
76
79
  ): string {
77
- const path = where === "project" ? projectSarvamPath(cwd) : profileSarvamPath();
78
- mkdirSync(dirname(path), { recursive: true });
80
+ const path = where === "project" ? projectSarvamPath(cwd) : profileSarvamPath(home);
79
81
  const body: Record<string, unknown> = {
80
82
  id: config.id ?? "sarvam",
81
83
  enabled: config.enabled !== false,
@@ -84,8 +86,8 @@ export function saveSarvamConfig(
84
86
  if (config.baseUrl) body.baseUrl = config.baseUrl;
85
87
  if (config.command) body.command = config.command;
86
88
  // Only persist key if caller intentionally set it (e.g. interactive connect with key arg).
87
- if (config.apiKey) body.apiKey = config.apiKey;
88
- writeFileSync(path, JSON.stringify(body, null, 2) + "\n", "utf8");
89
+ if (where === "profile" && config.apiKey) body.apiKey = config.apiKey;
90
+ atomicWritePrivateFileSync(path, JSON.stringify(body, null, 2) + "\n");
89
91
  return path;
90
92
  }
91
93
 
@@ -118,9 +120,9 @@ export function resolveSarvamConfig(opts: ResolveSarvamOptions = {}): SarvamHost
118
120
 
119
121
  const fromArgKey = opts.apiKey?.trim();
120
122
  const fromEnvKey = env[ENV_KEY]?.trim();
121
- const fromProject = configFromRecord(readJsonFile(projectSarvamPath(cwd)));
123
+ const fromProject = configFromRecord(readJsonFile(projectSarvamPath(cwd)), false);
122
124
  const fromProfile = configFromRecord(
123
- readJsonFile(profileSarvamPath(opts.home ?? homedir())),
125
+ readJsonFile(profileSarvamPath(opts.home ?? homedir()), true),
124
126
  );
125
127
 
126
128
  const fileBase = fromProject ?? fromProfile;
@@ -9,7 +9,13 @@ import { join } from "node:path";
9
9
 
10
10
  const FALLBACK_EDITOR = "vi";
11
11
 
12
+ export interface TerminalHandoff {
13
+ stdin: { isRaw?: boolean; setRawMode?: (enabled: boolean) => unknown };
14
+ stdout: { isTTY?: boolean; write: (text: string) => unknown };
15
+ }
16
+
12
17
  export interface ExternalEditorOptions {
18
+ readonly terminal?: TerminalHandoff;
13
19
  readonly env?: NodeJS.ProcessEnv;
14
20
  readonly dir?: string;
15
21
  }
@@ -33,7 +39,17 @@ export function openInExternalEditor(
33
39
  const [editor, ...editorArgs] = resolveEditor(env);
34
40
  try {
35
41
  writeFileSync(path, buffer, "utf8");
36
- const result = spawnSync(editor, [...editorArgs, path], { stdio: "inherit" });
42
+ const terminal = options.terminal ?? {stdin:process.stdin,stdout:process.stdout};
43
+ const wasRaw = terminal.stdin.isRaw === true;
44
+ let result: ReturnType<typeof spawnSync>;
45
+ try {
46
+ terminal.stdin.setRawMode?.(false);
47
+ if (terminal.stdout.isTTY) terminal.stdout.write("\x1b[?1049h\x1b[?25h");
48
+ result = spawnSync(editor, [...editorArgs, path], { stdio: "inherit" });
49
+ } finally {
50
+ if (terminal.stdout.isTTY) terminal.stdout.write("\x1b[?1049l\x1b[?25l");
51
+ terminal.stdin.setRawMode?.(wasRaw);
52
+ }
37
53
  if (result.error || result.status !== 0) return null;
38
54
  return readFileSync(path, "utf8").replace(/\n$/, "");
39
55
  } catch {
@@ -0,0 +1,31 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /** Execute an explicitly requested shell command with bounded output and cancellation. */
4
+ export function runShell(command: string, cwd: string, signal?: AbortSignal): Promise<{output:string;exitCode:number}> {
5
+ if (signal?.aborted) return Promise.resolve({output:'Command cancelled.',exitCode:130});
6
+ return new Promise(resolve => {
7
+ let output='', truncated=false, settled=false;
8
+ let forceKill:ReturnType<typeof setTimeout> | undefined;
9
+ const child=spawn(command,{cwd,shell:true,detached:process.platform!=='win32'});
10
+ const capture=(chunk:Buffer|string)=>{
11
+ output+=String(chunk);
12
+ if (output.length>1024*1024) {output=output.slice(-1024*1024);truncated=true;}
13
+ };
14
+ const kill=(signalName:NodeJS.Signals)=>{
15
+ try { if (process.platform!=='win32' && child.pid) process.kill(-child.pid,signalName); else child.kill(signalName); }
16
+ catch { try { child.kill(signalName); } catch { /* already exited */ } }
17
+ };
18
+ const abort=()=>{kill('SIGTERM');forceKill=setTimeout(()=>kill('SIGKILL'),1000);forceKill.unref();};
19
+ const finish=(exitCode:number)=>{
20
+ if (settled) return;settled=true;
21
+ signal?.removeEventListener('abort',abort);
22
+ if (forceKill) clearTimeout(forceKill);
23
+ resolve({output:(truncated?'[older output truncated]\n':'')+output,exitCode:signal?.aborted?130:exitCode});
24
+ };
25
+ child.stdout?.on('data',capture);child.stderr?.on('data',capture);
26
+ child.once('error',error=>{capture(error.message);finish(1)});
27
+ child.once('close',code=>finish(code??1));
28
+ signal?.addEventListener('abort',abort,{once:true});
29
+ if (signal?.aborted) abort();
30
+ });
31
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { ModelRegistry, type KnownProvider, type Model } from "indusagi/ai";
5
5
  import type { CatalogFilter } from "./contract.js";
6
+ import { applyCodingAgentModelOverlay } from "../models.js";
6
7
 
7
8
  export interface CatalogIo {
8
9
  print(line: string): void;
@@ -22,10 +23,11 @@ export interface CatalogModelSource {
22
23
  }
23
24
 
24
25
  export function registrySource(registry: ModelRegistry = new ModelRegistry()): CatalogModelSource {
26
+ const overlaid = applyCodingAgentModelOverlay(registry);
25
27
  return {
26
- providers: (): readonly (KnownProvider | string)[] => registry.getProviders(),
28
+ providers: (): readonly (KnownProvider | string)[] => overlaid.getProviders(),
27
29
  models: (provider: string): readonly Model<Model<string>["api"]>[] =>
28
- registry.getModels(provider),
30
+ overlaid.getModels(provider),
29
31
  };
30
32
  }
31
33
 
@@ -33,6 +33,7 @@ export const THINKING_EFFORTS = [
33
33
  "medium",
34
34
  "high",
35
35
  "xhigh",
36
+ "max",
36
37
  ] as const;
37
38
 
38
39
  export type ThinkingEffort = (typeof THINKING_EFFORTS)[number];
@@ -20,7 +20,7 @@ export const FLAG_GROUPS: readonly { readonly id: FlagGroup; readonly title: str
20
20
  { id: "meta", title: "Meta" },
21
21
  ];
22
22
 
23
- const THINKING_CHOICES = ["off", "minimal", "low", "medium", "high", "xhigh"];
23
+ const THINKING_CHOICES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
24
24
 
25
25
  export const FLAG_SPECS: readonly GroupedFlagSpec[] = [
26
26
  /* ---- Output ---------------------------------------------------------- */
@@ -110,6 +110,18 @@ export const FLAG_SPECS: readonly GroupedFlagSpec[] = [
110
110
  group: "context",
111
111
  describe: "Continue the most recent session in this directory.",
112
112
  },
113
+ {
114
+ name: "--trust-project-addons",
115
+ kind: "boolean",
116
+ group: "context",
117
+ describe: "Trust executable project addons for the canonical workspace and their current content digest.",
118
+ },
119
+ {
120
+ name: "--allow-context-import",
121
+ kind: "list",
122
+ group: "context",
123
+ describe: "Explicitly approve an exact external context file (comma-separated or repeated). Secret-like files remain blocked.",
124
+ },
113
125
  /* ---- Tools ----------------------------------------------------------- */
114
126
  {
115
127
  name: "--tools",
@@ -9,6 +9,7 @@ import {
9
9
  isToolName,
10
10
  } from "../contract.js";
11
11
  import { FLAG_SPECS, type GroupedFlagSpec } from "./flags.js";
12
+ import { isReasoningEffortSupported } from "../../models.js";
12
13
 
13
14
  interface ParseState {
14
15
  flags: Record<string, FlagValue>;
@@ -159,9 +160,9 @@ function deriveMode(flags: Record<string, FlagValue>): Invocation["mode"] {
159
160
  return "text";
160
161
  }
161
162
 
162
- function resolveThinking(flags: Record<string, FlagValue>): Invocation["thinking"] {
163
+ function resolveThinking(flags: Record<string, FlagValue>, modelId: string | undefined): Invocation["thinking"] {
163
164
  const raw = readString(flags, "thinking");
164
- return raw !== undefined && isThinkingEffort(raw) ? raw : undefined;
165
+ return raw !== undefined && isThinkingEffort(raw) && isReasoningEffortSupported(modelId, raw) ? raw : undefined;
165
166
  }
166
167
 
167
168
  function resolveTools(flags: Record<string, FlagValue>): Invocation["tools"] {
@@ -215,7 +216,7 @@ export function readInvocation(argv: readonly string[]): Invocation {
215
216
  const cwd = readString(flags, "cwd");
216
217
  const system = readString(flags, "system");
217
218
  const appendSystem = readString(flags, "append-system");
218
- const thinking = resolveThinking(flags);
219
+ const thinking = resolveThinking(flags, model);
219
220
  const tools = resolveTools(flags);
220
221
  return {
221
222
  mode,
@@ -6,6 +6,8 @@
6
6
  * model turns through the server gateway.
7
7
  */
8
8
  import { createInterface } from "node:readline/promises";
9
+ import { setTimeout as delay } from "node:timers/promises";
10
+ import type { ServerTokenInput } from "../boot/server-token.js";
9
11
  import chalk from "chalk";
10
12
  import type { OAuthLoginCallbacks } from "indusagi/ai";
11
13
  import type { BootContext } from "../boot/contract.js";
@@ -37,11 +39,15 @@ function err(line: string): void {
37
39
  process.stderr.write(`${line}\n`);
38
40
  }
39
41
 
40
- function sleep(ms: number): Promise<void> {
41
- return new Promise((resolve) => setTimeout(resolve, ms));
42
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
43
+ return delay(ms, undefined, {signal});
42
44
  }
43
45
 
44
46
  interface DeviceCodeResponse {
47
+ access_token?: string;
48
+ refresh_token?: string;
49
+ token_type?: string;
50
+ scope?: string;
45
51
  device_code?: string;
46
52
  user_code?: string;
47
53
  verification_uri?: string;
@@ -56,6 +62,7 @@ interface DeviceCodeResponse {
56
62
  async function postJson(
57
63
  url: string,
58
64
  body: Record<string, unknown>,
65
+ signal?: AbortSignal,
59
66
  ): Promise<{ status: number; data: DeviceCodeResponse }> {
60
67
  const res = await fetch(url, {
61
68
  method: "POST",
@@ -65,6 +72,7 @@ async function postJson(
65
72
  "user-agent": USER_AGENT,
66
73
  },
67
74
  body: JSON.stringify(body),
75
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000),
68
76
  });
69
77
  const data = (await res.json().catch(() => ({}))) as DeviceCodeResponse;
70
78
  return { status: res.status, data };
@@ -112,11 +120,12 @@ async function confirm(prompt: string, defaultValue: boolean): Promise<boolean>
112
120
  async function requestDeviceCode(
113
121
  serverUrl: string,
114
122
  clientId: string,
123
+ signal?: AbortSignal,
115
124
  ): Promise<DeviceCodeResponse> {
116
125
  const { status, data } = await postJson(`${serverUrl}/api/auth/device/code`, {
117
126
  client_id: clientId,
118
127
  scope: DEVICE_SCOPE,
119
- });
128
+ }, signal);
120
129
  if (status >= 400 || typeof data.device_code !== "string") {
121
130
  throw new LoginError(
122
131
  `Authorization request failed: ${data.error_description ?? data.error ?? data.message ?? `HTTP ${status}`}`,
@@ -129,18 +138,19 @@ async function pollForToken(
129
138
  serverUrl: string,
130
139
  clientId: string,
131
140
  device: DeviceCodeResponse,
132
- ): Promise<DeviceCodeResponse> {
141
+ signal?: AbortSignal,
142
+ ): Promise<ServerTokenInput> {
133
143
  let intervalMs = Math.max(1, device.interval ?? 5) * 1000;
134
144
  const deadline = Date.now() + (device.expires_in ?? 0) * 1000;
135
145
  while (Date.now() < deadline) {
136
- await sleep(intervalMs);
146
+ await sleep(intervalMs, signal);
137
147
  const { data } = await postJson(`${serverUrl}/api/auth/device/token`, {
138
148
  grant_type: DEVICE_GRANT_TYPE,
139
149
  device_code: device.device_code,
140
150
  client_id: clientId,
141
- });
151
+ }, signal);
142
152
  if (typeof data.access_token === "string") {
143
- return data;
153
+ return {...data, access_token:data.access_token};
144
154
  }
145
155
  const code = data.error;
146
156
  switch (code) {
@@ -172,14 +182,17 @@ export async function runServerTierLogin(
172
182
  serverUrl: string = INDUS_SERVER_URL,
173
183
  clientId: string = INDUS_CLIENT_ID,
174
184
  ): Promise<void> {
175
- const device = await requestDeviceCode(serverUrl, clientId);
185
+ callbacks.signal?.throwIfAborted();
186
+ const device = await requestDeviceCode(serverUrl, clientId, callbacks.signal);
187
+ callbacks.signal?.throwIfAborted();
176
188
  callbacks.onAuth({
177
189
  url: device.verification_uri_complete ?? device.verification_uri ?? "",
178
190
  instructions: `Enter code ${device.user_code} to authorize this device.`,
179
191
  });
180
192
  callbacks.onProgress?.("Waiting for authorization...");
181
- const token = await pollForToken(serverUrl, clientId, device);
182
- await storeServerToken(token);
193
+ const token = await pollForToken(serverUrl, clientId, device, callbacks.signal);
194
+ callbacks.signal?.throwIfAborted();
195
+ if (!await storeServerToken(token)) throw new LoginError("Could not save the server token.");
183
196
  callbacks.onProgress?.("Server sign-in complete.");
184
197
  }
185
198
 
@@ -195,8 +195,11 @@ export async function startOAuthLogin(
195
195
  envVar !== undefined ? `set ${envVar} and restart` : "configure its OAuth client id";
196
196
  throw new Error(`Browser sign-in for "${providerId}" is not configured: ${hint}.`);
197
197
  }
198
+ callbacks.signal?.throwIfAborted();
198
199
  const credentials = await provider.login(callbacks);
200
+ callbacks.signal?.throwIfAborted();
199
201
  const existing = await vault.listAccounts(providerId);
202
+ callbacks.signal?.throwIfAborted();
200
203
  const madeDefault = existing.length === 0;
201
204
  await vault.putOAuth(providerId, account, credentials, madeDefault);
202
205
  return { provider: providerId, account, madeDefault };