indusagi 0.12.34 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/agent.js +1247 -184
  2. package/dist/ai.js +72 -4
  3. package/dist/capabilities.js +69 -2
  4. package/dist/cli.js +83 -13
  5. package/dist/connectors-saas.js +66 -0
  6. package/dist/index.js +83 -13
  7. package/dist/interop.js +66 -0
  8. package/dist/mcp.js +270 -363
  9. package/dist/react-ink.js +15 -11
  10. package/dist/shell-app.js +83 -13
  11. package/dist/smithy.js +69 -2
  12. package/dist/swarm.js +69 -2
  13. package/dist/types/capabilities/backends/node-backends.d.ts +3 -1
  14. package/dist/types/capabilities/files/read-state-gate.d.ts +69 -0
  15. package/dist/types/capabilities/files/read-state-gate.test.d.ts +14 -0
  16. package/dist/types/capabilities/kernel/context.d.ts +4 -0
  17. package/dist/types/capabilities/kernel/index.d.ts +2 -2
  18. package/dist/types/capabilities/kernel/spec.d.ts +55 -0
  19. package/dist/types/facade/bot/actions/bash.d.ts +15 -0
  20. package/dist/types/facade/bot/actions/bash.test.d.ts +1 -0
  21. package/dist/types/facade/bot/actions/checkpoint.d.ts +49 -0
  22. package/dist/types/facade/bot/actions/checkpoint.test.d.ts +1 -0
  23. package/dist/types/facade/bot/actions/edit-utils.d.ts +86 -0
  24. package/dist/types/facade/bot/actions/edit.d.ts +18 -0
  25. package/dist/types/facade/bot/actions/edit.test.d.ts +1 -0
  26. package/dist/types/facade/bot/actions/find.d.ts +2 -0
  27. package/dist/types/facade/bot/actions/find.test.d.ts +1 -0
  28. package/dist/types/facade/bot/actions/grep.d.ts +10 -0
  29. package/dist/types/facade/bot/actions/grep.test.d.ts +1 -0
  30. package/dist/types/facade/bot/actions/index.d.ts +16 -0
  31. package/dist/types/facade/bot/actions/read-state.d.ts +83 -0
  32. package/dist/types/facade/bot/actions/read-state.test.d.ts +1 -0
  33. package/dist/types/facade/bot/actions/read.d.ts +7 -0
  34. package/dist/types/facade/bot/actions/read.test.d.ts +1 -0
  35. package/dist/types/facade/bot/actions/sandbox-backend.d.ts +99 -0
  36. package/dist/types/facade/bot/actions/sandbox-backend.test.d.ts +1 -0
  37. package/dist/types/facade/bot/actions/websearch.d.ts +5 -2
  38. package/dist/types/facade/bot/actions/websearch.test.d.ts +1 -0
  39. package/dist/types/facade/bot/actions/write.d.ts +15 -0
  40. package/dist/types/facade/bot/agent-loop.d.ts +10 -0
  41. package/dist/types/facade/bot/agent-loop.test.d.ts +1 -0
  42. package/dist/types/facade/bot/agent.d.ts +9 -1
  43. package/dist/types/facade/bot/permission-gate.test.d.ts +1 -0
  44. package/dist/types/facade/bot/types.d.ts +60 -0
  45. package/dist/types/facade/mcp-core/client.d.ts +71 -15
  46. package/dist/types/facade/mcp-core/client.test.d.ts +18 -0
  47. package/dist/types/facade/mcp-core/types.d.ts +10 -0
  48. package/dist/types/facade/ml/adapters/anthropic-retry.test.d.ts +1 -0
  49. package/dist/types/facade/ml/adapters/anthropic.d.ts +17 -0
  50. package/dist/types/facade/ml/adapters/simple-options.d.ts +13 -0
  51. package/dist/types/facade/ml/adapters/simple-options.test.d.ts +1 -0
  52. package/dist/types/react-ink/components/StatusLine.d.ts +10 -1
  53. package/dist/types/react-ink/components/ToolEventBlock.d.ts +2 -1
  54. package/dist/types/react-ink/components/ToolEventBlock.test.d.ts +1 -0
  55. package/package.json +1 -1
package/dist/react-ink.js CHANGED
@@ -2061,6 +2061,9 @@ function statusMarker(status) {
2061
2061
  return ">";
2062
2062
  }
2063
2063
  }
2064
+ function statusColorKey(status) {
2065
+ return status === "error" ? "error" : "text";
2066
+ }
2064
2067
  function plainToolText(text) {
2065
2068
  return stripAnsi5(text);
2066
2069
  }
@@ -2102,7 +2105,7 @@ function ToolEventBlock({
2102
2105
  showTitle = true,
2103
2106
  status,
2104
2107
  summary,
2105
- theme: _theme,
2108
+ theme,
2106
2109
  title
2107
2110
  }) {
2108
2111
  const normalizedSummary = summary?.trim();
@@ -2118,19 +2121,19 @@ function ToolEventBlock({
2118
2121
  const { visibleLines, hiddenLineCount } = clampContentLines(combinedLines, maxContentLines);
2119
2122
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom, marginLeft: indent, children: [
2120
2123
  showTitle ? /* @__PURE__ */ jsxs(Box, { children: [
2121
- /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(`${statusMarker(status)} `) }),
2122
- /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(title) }),
2123
- showSummaryInline && normalizedSummary ? /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(` (${normalizedSummary})`) }) : null
2124
+ /* @__PURE__ */ jsx(Text, { children: theme.color(statusColorKey(status), plainToolText(`${statusMarker(status)} `)) }),
2125
+ /* @__PURE__ */ jsx(Text, { children: theme.color(statusColorKey(status), plainToolText(title)) }),
2126
+ showSummaryInline && normalizedSummary ? /* @__PURE__ */ jsx(Text, { children: theme.muted(plainToolText(` (${normalizedSummary})`)) }) : null
2124
2127
  ] }) : null,
2125
2128
  visibleLines.map((line, index) => /* @__PURE__ */ jsx(
2126
2129
  Box,
2127
2130
  {
2128
2131
  marginLeft: line.kind === "response" ? showTitle ? 2 : 0 : showTitle ? 4 : 2,
2129
- children: preformatted ? /* @__PURE__ */ jsx(Text, { children: line.text }) : /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(line.text) })
2132
+ children: preformatted ? /* @__PURE__ */ jsx(Text, { children: line.text }) : /* @__PURE__ */ jsx(Text, { children: theme.color("text", plainToolText(line.text)) })
2130
2133
  },
2131
2134
  `${line.kind}:${index}:${stripAnsi5(line.text)}`
2132
2135
  )),
2133
- hiddenLineCount > 0 ? /* @__PURE__ */ jsx(Box, { marginLeft: showTitle ? 4 : 2, children: /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(`... ${hiddenLineCount} more line(s)`) }) }) : null
2136
+ hiddenLineCount > 0 ? /* @__PURE__ */ jsx(Box, { marginLeft: showTitle ? 4 : 2, children: /* @__PURE__ */ jsx(Text, { children: theme.muted(plainToolText(`... ${hiddenLineCount} more line(s)`)) }) }) : null
2134
2137
  ] });
2135
2138
  }
2136
2139
 
@@ -2473,20 +2476,20 @@ function MessageList({
2473
2476
  }
2474
2477
 
2475
2478
  // src/react-ink/components/StatusLine.tsx
2476
- function StatusLine({ snapshot, status, theme }) {
2479
+ function StatusLine({ snapshot, status, theme, showBusyText = true }) {
2477
2480
  let text = status?.text;
2478
2481
  let tone = status?.kind ?? "info";
2479
2482
  if (!text) {
2480
- if (snapshot.isCompacting) {
2483
+ if (showBusyText && snapshot.isCompacting) {
2481
2484
  text = "Compacting conversation context...";
2482
2485
  tone = "busy";
2483
- } else if (snapshot.isBashRunning) {
2486
+ } else if (showBusyText && snapshot.isBashRunning) {
2484
2487
  text = "Running bash command...";
2485
2488
  tone = "busy";
2486
- } else if (snapshot.pendingToolCallCount > 0 || snapshot.pendingMessageCount > 0) {
2489
+ } else if (showBusyText && (snapshot.pendingToolCallCount > 0 || snapshot.pendingMessageCount > 0)) {
2487
2490
  text = "Agent working...";
2488
2491
  tone = "busy";
2489
- } else if (snapshot.isStreaming) {
2492
+ } else if (showBusyText && snapshot.isStreaming) {
2490
2493
  text = "Agent working...";
2491
2494
  tone = "busy";
2492
2495
  } else if (snapshot.error) {
@@ -3815,6 +3818,7 @@ export {
3815
3818
  previewText,
3816
3819
  safeStringify,
3817
3820
  splitAssistantMessage,
3821
+ statusColorKey,
3818
3822
  stringWidth,
3819
3823
  wordDiffLine,
3820
3824
  wrapSelectionIndex
package/dist/shell-app.js CHANGED
@@ -4635,6 +4635,7 @@ function runErrorThrowable(kind, message) {
4635
4635
  }
4636
4636
 
4637
4637
  // src/capabilities/kernel/spec.ts
4638
+ var READ_STATE_HANDLE_KEY = "readState";
4638
4639
  function coerceInput(raw) {
4639
4640
  if (typeof raw === "string") {
4640
4641
  const trimmed = raw.trim();
@@ -5088,7 +5089,7 @@ var standardBudget = {
5088
5089
  [${omitted} bytes elided to stay within the output ceiling]
5089
5090
  `
5090
5091
  };
5091
- function makeNodeContext(cwd, signal, budget) {
5092
+ function makeNodeContext(cwd, signal, budget, framework) {
5092
5093
  if (typeof cwd !== "string" || cwd.length === 0) {
5093
5094
  throw new Error("makeNodeContext requires a non-empty working directory.");
5094
5095
  }
@@ -5097,10 +5098,60 @@ function makeNodeContext(cwd, signal, budget) {
5097
5098
  fs: nodeFs,
5098
5099
  shell: nodeShell,
5099
5100
  signal: signal ?? neverAborts(),
5100
- budget: budget ?? standardBudget
5101
+ budget: budget ?? standardBudget,
5102
+ ...framework ? { framework } : {}
5101
5103
  };
5102
5104
  }
5103
5105
 
5106
+ // src/capabilities/files/read-state-gate.ts
5107
+ var READ_BEFORE_EDIT_MESSAGE = "File has not been read yet. Read it first before writing to it.";
5108
+ var MODIFIED_SINCE_READ_MESSAGE = "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";
5109
+ function getReadStateHandle(ctx) {
5110
+ const bag = ctx.framework;
5111
+ if (!bag || typeof bag !== "object") return void 0;
5112
+ const candidate = bag[READ_STATE_HANDLE_KEY];
5113
+ if (!candidate || typeof candidate !== "object") return void 0;
5114
+ const handle = candidate;
5115
+ if (typeof handle.get !== "function" || typeof handle.set !== "function" || typeof handle.has !== "function") {
5116
+ return void 0;
5117
+ }
5118
+ return handle;
5119
+ }
5120
+ async function recordReadState(ctx, absPath, handle) {
5121
+ if (!handle) return;
5122
+ try {
5123
+ const info = await ctx.fs.stat(absPath);
5124
+ const record = {
5125
+ mtimeMs: Math.floor(info.modifiedMs),
5126
+ size: info.size,
5127
+ readAt: Date.now()
5128
+ };
5129
+ handle.set(absPath, record);
5130
+ } catch {
5131
+ }
5132
+ }
5133
+ async function enforceReadGate(ctx, absPath, handle) {
5134
+ if (!handle) return { ok: true };
5135
+ if (!handle.has(absPath)) {
5136
+ return { ok: false, message: READ_BEFORE_EDIT_MESSAGE };
5137
+ }
5138
+ const recorded = handle.get(absPath);
5139
+ if (!recorded) {
5140
+ return { ok: false, message: READ_BEFORE_EDIT_MESSAGE };
5141
+ }
5142
+ let info;
5143
+ try {
5144
+ info = await ctx.fs.stat(absPath);
5145
+ } catch {
5146
+ return { ok: true };
5147
+ }
5148
+ const currentMtime = Math.floor(info.modifiedMs);
5149
+ if (currentMtime > recorded.mtimeMs || info.size !== recorded.size) {
5150
+ return { ok: false, message: MODIFIED_SINCE_READ_MESSAGE };
5151
+ }
5152
+ return { ok: true };
5153
+ }
5154
+
5104
5155
  // src/capabilities/files/read.ts
5105
5156
  var GUTTER_WIDTH = 6;
5106
5157
  var DESCRIPTION = [
@@ -5203,6 +5254,7 @@ var readTool = defineTool({
5203
5254
  const detail = err instanceof Error ? err.message : String(err);
5204
5255
  return failure(`Could not read ${path2}: ${detail}`);
5205
5256
  }
5257
+ await recordReadState(ctx, path2, getReadStateHandle(ctx));
5206
5258
  const allLines = toLines(text);
5207
5259
  const totalLines = allLines.length;
5208
5260
  if (totalLines === 0) {
@@ -5310,11 +5362,19 @@ var writeTool = defineTool({
5310
5362
  async run(input, ctx) {
5311
5363
  const path2 = readPath(input?.path);
5312
5364
  const content = readContent(input?.content);
5365
+ const handle = getReadStateHandle(ctx);
5366
+ if (handle && await ctx.fs.exists(path2)) {
5367
+ const gate = await enforceReadGate(ctx, path2, handle);
5368
+ if (!gate.ok) {
5369
+ return asText(gate.message, true);
5370
+ }
5371
+ }
5313
5372
  const folder = parentDir(path2);
5314
5373
  if (folder.length > 0) {
5315
5374
  await ctx.fs.mkdir(folder, { recursive: true });
5316
5375
  }
5317
5376
  await ctx.fs.writeFile(path2, content, "utf8");
5377
+ await recordReadState(ctx, path2, handle);
5318
5378
  const bytes = Buffer.byteLength(content, "utf8");
5319
5379
  const unit = bytes === 1 ? "byte" : "bytes";
5320
5380
  return asText(`Saved ${bytes} ${unit} to ${path2}.`);
@@ -5604,6 +5664,11 @@ async function runEdit(input, ctx) {
5604
5664
  if (!info.isFile) {
5605
5665
  return failure2(`${path2} is not a regular file, so it cannot be edited.`);
5606
5666
  }
5667
+ const handle = getReadStateHandle(ctx);
5668
+ const gate = await enforceReadGate(ctx, path2, handle);
5669
+ if (!gate.ok) {
5670
+ return failure2(gate.message);
5671
+ }
5607
5672
  const before = await ctx.fs.readFile(path2, "utf8");
5608
5673
  const literalHits = countLiteral(before, oldText);
5609
5674
  if (literalHits > 0) {
@@ -5617,6 +5682,7 @@ async function runEdit(input, ctx) {
5617
5682
  return failure2(`The replacement left ${path2} unchanged.`);
5618
5683
  }
5619
5684
  await ctx.fs.writeFile(path2, after2, "utf8");
5685
+ await recordReadState(ctx, path2, handle);
5620
5686
  return success(path2, before, after2, replaceAll ? literalHits : 1);
5621
5687
  }
5622
5688
  const spans = findFuzzySpans(before, oldText);
@@ -5640,6 +5706,7 @@ async function runEdit(input, ctx) {
5640
5706
  return failure2(`The fuzzy replacement left ${path2} unchanged.`);
5641
5707
  }
5642
5708
  await ctx.fs.writeFile(path2, after, "utf8");
5709
+ await recordReadState(ctx, path2, handle);
5643
5710
  return success(path2, before, after, targets.length);
5644
5711
  }
5645
5712
  var editTool = defineTool({
@@ -10381,6 +10448,9 @@ function statusMarker(status) {
10381
10448
  return ">";
10382
10449
  }
10383
10450
  }
10451
+ function statusColorKey(status) {
10452
+ return status === "error" ? "error" : "text";
10453
+ }
10384
10454
  function plainToolText(text) {
10385
10455
  return stripAnsi5(text);
10386
10456
  }
@@ -10422,7 +10492,7 @@ function ToolEventBlock({
10422
10492
  showTitle = true,
10423
10493
  status,
10424
10494
  summary,
10425
- theme: _theme,
10495
+ theme,
10426
10496
  title
10427
10497
  }) {
10428
10498
  const normalizedSummary = summary?.trim();
@@ -10438,19 +10508,19 @@ function ToolEventBlock({
10438
10508
  const { visibleLines, hiddenLineCount } = clampContentLines(combinedLines, maxContentLines);
10439
10509
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom, marginLeft: indent, children: [
10440
10510
  showTitle ? /* @__PURE__ */ jsxs(Box, { children: [
10441
- /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(`${statusMarker(status)} `) }),
10442
- /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(title) }),
10443
- showSummaryInline && normalizedSummary ? /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(` (${normalizedSummary})`) }) : null
10511
+ /* @__PURE__ */ jsx(Text, { children: theme.color(statusColorKey(status), plainToolText(`${statusMarker(status)} `)) }),
10512
+ /* @__PURE__ */ jsx(Text, { children: theme.color(statusColorKey(status), plainToolText(title)) }),
10513
+ showSummaryInline && normalizedSummary ? /* @__PURE__ */ jsx(Text, { children: theme.muted(plainToolText(` (${normalizedSummary})`)) }) : null
10444
10514
  ] }) : null,
10445
10515
  visibleLines.map((line, index) => /* @__PURE__ */ jsx(
10446
10516
  Box,
10447
10517
  {
10448
10518
  marginLeft: line.kind === "response" ? showTitle ? 2 : 0 : showTitle ? 4 : 2,
10449
- children: preformatted ? /* @__PURE__ */ jsx(Text, { children: line.text }) : /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(line.text) })
10519
+ children: preformatted ? /* @__PURE__ */ jsx(Text, { children: line.text }) : /* @__PURE__ */ jsx(Text, { children: theme.color("text", plainToolText(line.text)) })
10450
10520
  },
10451
10521
  `${line.kind}:${index}:${stripAnsi5(line.text)}`
10452
10522
  )),
10453
- hiddenLineCount > 0 ? /* @__PURE__ */ jsx(Box, { marginLeft: showTitle ? 4 : 2, children: /* @__PURE__ */ jsx(Text, { color: "white", children: plainToolText(`... ${hiddenLineCount} more line(s)`) }) }) : null
10523
+ hiddenLineCount > 0 ? /* @__PURE__ */ jsx(Box, { marginLeft: showTitle ? 4 : 2, children: /* @__PURE__ */ jsx(Text, { children: theme.muted(plainToolText(`... ${hiddenLineCount} more line(s)`)) }) }) : null
10454
10524
  ] });
10455
10525
  }
10456
10526
 
@@ -10793,20 +10863,20 @@ function MessageList({
10793
10863
  }
10794
10864
 
10795
10865
  // src/react-ink/components/StatusLine.tsx
10796
- function StatusLine({ snapshot, status, theme }) {
10866
+ function StatusLine({ snapshot, status, theme, showBusyText = true }) {
10797
10867
  let text = status?.text;
10798
10868
  let tone = status?.kind ?? "info";
10799
10869
  if (!text) {
10800
- if (snapshot.isCompacting) {
10870
+ if (showBusyText && snapshot.isCompacting) {
10801
10871
  text = "Compacting conversation context...";
10802
10872
  tone = "busy";
10803
- } else if (snapshot.isBashRunning) {
10873
+ } else if (showBusyText && snapshot.isBashRunning) {
10804
10874
  text = "Running bash command...";
10805
10875
  tone = "busy";
10806
- } else if (snapshot.pendingToolCallCount > 0 || snapshot.pendingMessageCount > 0) {
10876
+ } else if (showBusyText && (snapshot.pendingToolCallCount > 0 || snapshot.pendingMessageCount > 0)) {
10807
10877
  text = "Agent working...";
10808
10878
  tone = "busy";
10809
- } else if (snapshot.isStreaming) {
10879
+ } else if (showBusyText && snapshot.isStreaming) {
10810
10880
  text = "Agent working...";
10811
10881
  tone = "busy";
10812
10882
  } else if (snapshot.error) {
package/dist/smithy.js CHANGED
@@ -276,6 +276,7 @@ function getProfile(name) {
276
276
  }
277
277
 
278
278
  // src/capabilities/kernel/spec.ts
279
+ var READ_STATE_HANDLE_KEY = "readState";
279
280
  function coerceInput(raw) {
280
281
  if (typeof raw === "string") {
281
282
  const trimmed = raw.trim();
@@ -729,7 +730,7 @@ var standardBudget = {
729
730
  [${omitted} bytes elided to stay within the output ceiling]
730
731
  `
731
732
  };
732
- function makeNodeContext(cwd, signal, budget) {
733
+ function makeNodeContext(cwd, signal, budget, framework) {
733
734
  if (typeof cwd !== "string" || cwd.length === 0) {
734
735
  throw new Error("makeNodeContext requires a non-empty working directory.");
735
736
  }
@@ -738,10 +739,60 @@ function makeNodeContext(cwd, signal, budget) {
738
739
  fs: nodeFs,
739
740
  shell: nodeShell,
740
741
  signal: signal ?? neverAborts(),
741
- budget: budget ?? standardBudget
742
+ budget: budget ?? standardBudget,
743
+ ...framework ? { framework } : {}
742
744
  };
743
745
  }
744
746
 
747
+ // src/capabilities/files/read-state-gate.ts
748
+ var READ_BEFORE_EDIT_MESSAGE = "File has not been read yet. Read it first before writing to it.";
749
+ var MODIFIED_SINCE_READ_MESSAGE = "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";
750
+ function getReadStateHandle(ctx) {
751
+ const bag = ctx.framework;
752
+ if (!bag || typeof bag !== "object") return void 0;
753
+ const candidate = bag[READ_STATE_HANDLE_KEY];
754
+ if (!candidate || typeof candidate !== "object") return void 0;
755
+ const handle = candidate;
756
+ if (typeof handle.get !== "function" || typeof handle.set !== "function" || typeof handle.has !== "function") {
757
+ return void 0;
758
+ }
759
+ return handle;
760
+ }
761
+ async function recordReadState(ctx, absPath, handle) {
762
+ if (!handle) return;
763
+ try {
764
+ const info = await ctx.fs.stat(absPath);
765
+ const record = {
766
+ mtimeMs: Math.floor(info.modifiedMs),
767
+ size: info.size,
768
+ readAt: Date.now()
769
+ };
770
+ handle.set(absPath, record);
771
+ } catch {
772
+ }
773
+ }
774
+ async function enforceReadGate(ctx, absPath, handle) {
775
+ if (!handle) return { ok: true };
776
+ if (!handle.has(absPath)) {
777
+ return { ok: false, message: READ_BEFORE_EDIT_MESSAGE };
778
+ }
779
+ const recorded = handle.get(absPath);
780
+ if (!recorded) {
781
+ return { ok: false, message: READ_BEFORE_EDIT_MESSAGE };
782
+ }
783
+ let info;
784
+ try {
785
+ info = await ctx.fs.stat(absPath);
786
+ } catch {
787
+ return { ok: true };
788
+ }
789
+ const currentMtime = Math.floor(info.modifiedMs);
790
+ if (currentMtime > recorded.mtimeMs || info.size !== recorded.size) {
791
+ return { ok: false, message: MODIFIED_SINCE_READ_MESSAGE };
792
+ }
793
+ return { ok: true };
794
+ }
795
+
745
796
  // src/capabilities/files/read.ts
746
797
  var GUTTER_WIDTH = 6;
747
798
  var DESCRIPTION = [
@@ -844,6 +895,7 @@ var readTool = defineTool({
844
895
  const detail = err instanceof Error ? err.message : String(err);
845
896
  return failure(`Could not read ${path}: ${detail}`);
846
897
  }
898
+ await recordReadState(ctx, path, getReadStateHandle(ctx));
847
899
  const allLines = toLines(text);
848
900
  const totalLines = allLines.length;
849
901
  if (totalLines === 0) {
@@ -951,11 +1003,19 @@ var writeTool = defineTool({
951
1003
  async run(input, ctx) {
952
1004
  const path = readPath(input?.path);
953
1005
  const content = readContent(input?.content);
1006
+ const handle = getReadStateHandle(ctx);
1007
+ if (handle && await ctx.fs.exists(path)) {
1008
+ const gate = await enforceReadGate(ctx, path, handle);
1009
+ if (!gate.ok) {
1010
+ return asText(gate.message, true);
1011
+ }
1012
+ }
954
1013
  const folder = parentDir(path);
955
1014
  if (folder.length > 0) {
956
1015
  await ctx.fs.mkdir(folder, { recursive: true });
957
1016
  }
958
1017
  await ctx.fs.writeFile(path, content, "utf8");
1018
+ await recordReadState(ctx, path, handle);
959
1019
  const bytes = Buffer.byteLength(content, "utf8");
960
1020
  const unit = bytes === 1 ? "byte" : "bytes";
961
1021
  return asText(`Saved ${bytes} ${unit} to ${path}.`);
@@ -1245,6 +1305,11 @@ async function runEdit(input, ctx) {
1245
1305
  if (!info.isFile) {
1246
1306
  return failure2(`${path} is not a regular file, so it cannot be edited.`);
1247
1307
  }
1308
+ const handle = getReadStateHandle(ctx);
1309
+ const gate = await enforceReadGate(ctx, path, handle);
1310
+ if (!gate.ok) {
1311
+ return failure2(gate.message);
1312
+ }
1248
1313
  const before = await ctx.fs.readFile(path, "utf8");
1249
1314
  const literalHits = countLiteral(before, oldText);
1250
1315
  if (literalHits > 0) {
@@ -1258,6 +1323,7 @@ async function runEdit(input, ctx) {
1258
1323
  return failure2(`The replacement left ${path} unchanged.`);
1259
1324
  }
1260
1325
  await ctx.fs.writeFile(path, after2, "utf8");
1326
+ await recordReadState(ctx, path, handle);
1261
1327
  return success(path, before, after2, replaceAll ? literalHits : 1);
1262
1328
  }
1263
1329
  const spans = findFuzzySpans(before, oldText);
@@ -1281,6 +1347,7 @@ async function runEdit(input, ctx) {
1281
1347
  return failure2(`The fuzzy replacement left ${path} unchanged.`);
1282
1348
  }
1283
1349
  await ctx.fs.writeFile(path, after, "utf8");
1350
+ await recordReadState(ctx, path, handle);
1284
1351
  return success(path, before, after, targets.length);
1285
1352
  }
1286
1353
  var editTool = defineTool({
package/dist/swarm.js CHANGED
@@ -4383,6 +4383,7 @@ function runErrorThrowable(kind, message) {
4383
4383
  }
4384
4384
 
4385
4385
  // src/capabilities/kernel/spec.ts
4386
+ var READ_STATE_HANDLE_KEY = "readState";
4386
4387
  function coerceInput(raw) {
4387
4388
  if (typeof raw === "string") {
4388
4389
  const trimmed = raw.trim();
@@ -4836,7 +4837,7 @@ var standardBudget = {
4836
4837
  [${omitted} bytes elided to stay within the output ceiling]
4837
4838
  `
4838
4839
  };
4839
- function makeNodeContext(cwd, signal, budget) {
4840
+ function makeNodeContext(cwd, signal, budget, framework) {
4840
4841
  if (typeof cwd !== "string" || cwd.length === 0) {
4841
4842
  throw new Error("makeNodeContext requires a non-empty working directory.");
4842
4843
  }
@@ -4845,10 +4846,60 @@ function makeNodeContext(cwd, signal, budget) {
4845
4846
  fs: nodeFs,
4846
4847
  shell: nodeShell,
4847
4848
  signal: signal ?? neverAborts(),
4848
- budget: budget ?? standardBudget
4849
+ budget: budget ?? standardBudget,
4850
+ ...framework ? { framework } : {}
4849
4851
  };
4850
4852
  }
4851
4853
 
4854
+ // src/capabilities/files/read-state-gate.ts
4855
+ var READ_BEFORE_EDIT_MESSAGE = "File has not been read yet. Read it first before writing to it.";
4856
+ var MODIFIED_SINCE_READ_MESSAGE = "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";
4857
+ function getReadStateHandle(ctx) {
4858
+ const bag = ctx.framework;
4859
+ if (!bag || typeof bag !== "object") return void 0;
4860
+ const candidate = bag[READ_STATE_HANDLE_KEY];
4861
+ if (!candidate || typeof candidate !== "object") return void 0;
4862
+ const handle = candidate;
4863
+ if (typeof handle.get !== "function" || typeof handle.set !== "function" || typeof handle.has !== "function") {
4864
+ return void 0;
4865
+ }
4866
+ return handle;
4867
+ }
4868
+ async function recordReadState(ctx, absPath, handle) {
4869
+ if (!handle) return;
4870
+ try {
4871
+ const info = await ctx.fs.stat(absPath);
4872
+ const record = {
4873
+ mtimeMs: Math.floor(info.modifiedMs),
4874
+ size: info.size,
4875
+ readAt: Date.now()
4876
+ };
4877
+ handle.set(absPath, record);
4878
+ } catch {
4879
+ }
4880
+ }
4881
+ async function enforceReadGate(ctx, absPath, handle) {
4882
+ if (!handle) return { ok: true };
4883
+ if (!handle.has(absPath)) {
4884
+ return { ok: false, message: READ_BEFORE_EDIT_MESSAGE };
4885
+ }
4886
+ const recorded = handle.get(absPath);
4887
+ if (!recorded) {
4888
+ return { ok: false, message: READ_BEFORE_EDIT_MESSAGE };
4889
+ }
4890
+ let info;
4891
+ try {
4892
+ info = await ctx.fs.stat(absPath);
4893
+ } catch {
4894
+ return { ok: true };
4895
+ }
4896
+ const currentMtime = Math.floor(info.modifiedMs);
4897
+ if (currentMtime > recorded.mtimeMs || info.size !== recorded.size) {
4898
+ return { ok: false, message: MODIFIED_SINCE_READ_MESSAGE };
4899
+ }
4900
+ return { ok: true };
4901
+ }
4902
+
4852
4903
  // src/capabilities/files/read.ts
4853
4904
  var GUTTER_WIDTH = 6;
4854
4905
  var DESCRIPTION = [
@@ -4951,6 +5002,7 @@ var readTool = defineTool({
4951
5002
  const detail = err instanceof Error ? err.message : String(err);
4952
5003
  return failure(`Could not read ${path}: ${detail}`);
4953
5004
  }
5005
+ await recordReadState(ctx, path, getReadStateHandle(ctx));
4954
5006
  const allLines = toLines(text);
4955
5007
  const totalLines = allLines.length;
4956
5008
  if (totalLines === 0) {
@@ -5058,11 +5110,19 @@ var writeTool = defineTool({
5058
5110
  async run(input, ctx) {
5059
5111
  const path = readPath(input?.path);
5060
5112
  const content = readContent(input?.content);
5113
+ const handle = getReadStateHandle(ctx);
5114
+ if (handle && await ctx.fs.exists(path)) {
5115
+ const gate = await enforceReadGate(ctx, path, handle);
5116
+ if (!gate.ok) {
5117
+ return asText(gate.message, true);
5118
+ }
5119
+ }
5061
5120
  const folder = parentDir(path);
5062
5121
  if (folder.length > 0) {
5063
5122
  await ctx.fs.mkdir(folder, { recursive: true });
5064
5123
  }
5065
5124
  await ctx.fs.writeFile(path, content, "utf8");
5125
+ await recordReadState(ctx, path, handle);
5066
5126
  const bytes = Buffer.byteLength(content, "utf8");
5067
5127
  const unit = bytes === 1 ? "byte" : "bytes";
5068
5128
  return asText(`Saved ${bytes} ${unit} to ${path}.`);
@@ -5352,6 +5412,11 @@ async function runEdit(input, ctx) {
5352
5412
  if (!info.isFile) {
5353
5413
  return failure2(`${path} is not a regular file, so it cannot be edited.`);
5354
5414
  }
5415
+ const handle = getReadStateHandle(ctx);
5416
+ const gate = await enforceReadGate(ctx, path, handle);
5417
+ if (!gate.ok) {
5418
+ return failure2(gate.message);
5419
+ }
5355
5420
  const before = await ctx.fs.readFile(path, "utf8");
5356
5421
  const literalHits = countLiteral(before, oldText);
5357
5422
  if (literalHits > 0) {
@@ -5365,6 +5430,7 @@ async function runEdit(input, ctx) {
5365
5430
  return failure2(`The replacement left ${path} unchanged.`);
5366
5431
  }
5367
5432
  await ctx.fs.writeFile(path, after2, "utf8");
5433
+ await recordReadState(ctx, path, handle);
5368
5434
  return success(path, before, after2, replaceAll ? literalHits : 1);
5369
5435
  }
5370
5436
  const spans = findFuzzySpans(before, oldText);
@@ -5388,6 +5454,7 @@ async function runEdit(input, ctx) {
5388
5454
  return failure2(`The fuzzy replacement left ${path} unchanged.`);
5389
5455
  }
5390
5456
  await ctx.fs.writeFile(path, after, "utf8");
5457
+ await recordReadState(ctx, path, handle);
5391
5458
  return success(path, before, after, targets.length);
5392
5459
  }
5393
5460
  var editTool = defineTool({
@@ -25,4 +25,6 @@ export declare const nodeShell: Shell;
25
25
  * required, while the cancellation `signal` and output `budget` fall back to a
26
26
  * never-aborting signal and the kernel's standard window when omitted.
27
27
  */
28
- export declare function makeNodeContext(cwd: string, signal?: AbortSignal, budget?: OutputBudget): ToolContext;
28
+ export declare function makeNodeContext(cwd: string, signal?: AbortSignal, budget?: OutputBudget, framework?: {
29
+ readonly [key: string]: unknown;
30
+ }): ToolContext;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The read-before-edit gate (shared by `read`, `edit`, and `write`).
3
+ *
4
+ * This is the framework half of the read-edit-gate feature. When — and ONLY
5
+ * when — a host has stashed a {@link ReadStateHandle} on
6
+ * `ctx.framework[READ_STATE_HANDLE_KEY]`, the file tools enforce a small
7
+ * discipline borrowed from interactive coding agents:
8
+ *
9
+ * 1. A file may not be edited or overwritten until it has been *read* in this
10
+ * session (so the model is never blindly clobbering content it has not
11
+ * seen).
12
+ * 2. A file may not be mutated if it has drifted on disk since that read — its
13
+ * modification time or byte size advanced past what was recorded — because
14
+ * that usually means a human or a linter changed it underneath us.
15
+ *
16
+ * After every successful read (and every successful mutation) the recorded state
17
+ * is refreshed from the fresh on-disk stat, so the next gate check compares
18
+ * against the latest known-good snapshot.
19
+ *
20
+ * The whole mechanism is *additive*: with no handle present, every helper here
21
+ * is a no-op and the tools behave exactly as they did before. Nothing imports
22
+ * from the product — the handle is duck-typed and the key is a shared string
23
+ * literal, so the framework consumer and the product injector agree on shape
24
+ * without a cross-package type import.
25
+ */
26
+ import type { ReadStateHandle, ToolContext } from "../kernel";
27
+ /** The two byte-stable refusal messages the gate emits. */
28
+ export declare const READ_BEFORE_EDIT_MESSAGE = "File has not been read yet. Read it first before writing to it.";
29
+ export declare const MODIFIED_SINCE_READ_MESSAGE = "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.";
30
+ /**
31
+ * Pull the read-state handle off the context, if one was injected.
32
+ *
33
+ * Returns `undefined` whenever the framework bag, the key, or a well-formed
34
+ * handle is absent — the gate then degrades to a no-op. The handle is validated
35
+ * structurally (it must expose `get` / `set` / `has`) so a malformed injection
36
+ * can never throw mid-mutation; it simply disables the gate.
37
+ */
38
+ export declare function getReadStateHandle(ctx: ToolContext): ReadStateHandle | undefined;
39
+ /**
40
+ * Record (or refresh) the read state for `absPath` from a fresh stat.
41
+ *
42
+ * No-op when no handle is present. Stats are taken through the kernel `fs` seam
43
+ * — its `modifiedMs` field is floored to a whole millisecond so a sub-ms clock
44
+ * skew between read and a later compare can never spuriously trip the staleness
45
+ * check. A failed stat is swallowed: state-keeping is best-effort and must never
46
+ * mask the real operation's outcome.
47
+ */
48
+ export declare function recordReadState(ctx: ToolContext, absPath: string, handle: ReadStateHandle | undefined): Promise<void>;
49
+ /** Outcome of a gate check: either cleared, or refused with a message. */
50
+ export type GateOutcome = {
51
+ ok: true;
52
+ } | {
53
+ ok: false;
54
+ message: string;
55
+ };
56
+ /**
57
+ * Enforce the read-before-edit + staleness gate ahead of a mutation.
58
+ *
59
+ * With no handle present this always clears (the gate is opt-in). With a handle:
60
+ * - refuse when `absPath` has no recorded read this session;
61
+ * - refuse when the on-disk modification time or byte size has advanced past
62
+ * the recorded read.
63
+ *
64
+ * The on-disk stat is read through the kernel `fs` seam and floored the same way
65
+ * the recorded mtime is, so the comparison is apples-to-apples. A stat failure
66
+ * (e.g. the file vanished) clears the gate so the underlying tool can surface
67
+ * its own, more specific error.
68
+ */
69
+ export declare function enforceReadGate(ctx: ToolContext, absPath: string, handle: ReadStateHandle | undefined): Promise<GateOutcome>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Tests for the read-before-edit gate (framework half of read-edit-gate).
3
+ *
4
+ * The gate is opt-in: it activates only when a host injects a {@link ReadStateHandle}
5
+ * on `ctx.framework[READ_STATE_HANDLE_KEY]`. These cases exercise both worlds —
6
+ * the gate ACTIVE (handle present) and the gate ABSENT (no handle, today's
7
+ * behavior) — across the three file tools (`read`, `edit`, `write`).
8
+ *
9
+ * Everything runs against a real on-disk sandbox via the genuine
10
+ * {@link makeNodeContext}, so the tools take their real filesystem path. The
11
+ * injected store is a tiny in-memory Map, mirroring what the product host will
12
+ * supply through the shared string-literal handle key.
13
+ */
14
+ export {};
@@ -28,6 +28,10 @@ export interface ContextOverrides {
28
28
  readonly shell?: Shell;
29
29
  readonly signal?: AbortSignal;
30
30
  readonly budget?: OutputBudget;
31
+ /** Optional open bag of host-injected framework handles (e.g. the read-state store). */
32
+ readonly framework?: {
33
+ readonly [key: string]: unknown;
34
+ };
31
35
  }
32
36
  /**
33
37
  * Build a {@link ToolContext}, defaulting every field the caller omits.
@@ -13,8 +13,8 @@
13
13
  * re-exported here; consumers import those straight from "../../runtime/contract"
14
14
  * and "../../llmgateway/contract" so there is a single source of truth.
15
15
  */
16
- export { defineTool } from "./spec";
17
- export type { ToolSpec, ToolResult, ToolContentBlock, TextContentBlock, JsonContentBlock, ToolContext, DefinedTool, } from "./spec";
16
+ export { defineTool, READ_STATE_HANDLE_KEY } from "./spec";
17
+ export type { ToolSpec, ToolResult, ToolContentBlock, TextContentBlock, JsonContentBlock, ToolContext, DefinedTool, ReadStateHandle, ReadStateRecord, } from "./spec";
18
18
  export type { Fs, FsEntryInfo, DirChild, RemoveOptions, Shell, ShellLaunchOptions, ShellExecOptions, ShellExecResult, ShellHandle, } from "./backends";
19
19
  export { clamp } from "./output";
20
20
  export type { OutputBudget, ClipEnd } from "./output";