mini-coder 0.5.12 → 0.5.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.
package/src/tools.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Built-in tool implementations: `shell`, `read`, `grep`, `edit`, `todoWrite`,
3
- * `todoRead`, and `readImage`.
2
+ * Built-in tool implementations: `shell`, `delegate`, `read`, `grep`, `edit`,
3
+ * `todoWrite`, `todoRead`, and `readImage`.
4
4
  *
5
- * Shell, read, and grep live in dedicated modules and are re-exported here so
6
- * the rest of the codebase can keep a single built-in-tools import surface.
5
+ * Shell, delegate, read, and grep live in dedicated modules and are re-exported
6
+ * here so the rest of the codebase can keep a single built-in-tools import
7
+ * surface.
7
8
  *
8
9
  * Each tool is exposed as a pure-ish execute function that takes typed
9
10
  * arguments and a working directory, returning a result object. The pi-ai
@@ -15,6 +16,7 @@
15
16
 
16
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
18
  import { dirname, extname, isAbsolute, join } from "node:path";
19
+ import { crc32, inflateSync } from "node:zlib";
18
20
  import type {
19
21
  Message,
20
22
  Static,
@@ -33,6 +35,15 @@ import {
33
35
  } from "./tool-common.ts";
34
36
 
35
37
  export type { ToolExecResult } from "./tool-common.ts";
38
+ export {
39
+ type CreateDelegateToolHandlerOpts,
40
+ createDelegateToolHandler,
41
+ type DelegateArgs,
42
+ type DelegateResultDetails,
43
+ type DelegateRunResult,
44
+ delegateTool,
45
+ formatDelegateResultText,
46
+ } from "./tool-delegate.ts";
36
47
  export {
37
48
  DEFAULT_GREP_LIMIT,
38
49
  executeGrep,
@@ -58,9 +69,15 @@ export {
58
69
  readToolHandler,
59
70
  } from "./tool-read.ts";
60
71
  export {
72
+ createDelegationAwareShellToolHandler,
73
+ type DelegationAwareShellToolHandlerOpts,
61
74
  executeShell,
75
+ formatShellResultText,
76
+ parseLegacyShellResult,
77
+ parseShellResultDetails,
62
78
  type ShellArgs,
63
79
  type ShellOpts,
80
+ type ShellResultDetails,
64
81
  shellTool,
65
82
  shellToolHandler,
66
83
  truncateOutput,
@@ -699,6 +716,312 @@ const IMAGE_MIME_TYPES: Record<string, string> = {
699
716
  ".webp": "image/webp",
700
717
  };
701
718
 
719
+ const PNG_SIGNATURE = Buffer.from([
720
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
721
+ ]);
722
+ const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8]);
723
+ const GIF_SIGNATURES = ["GIF87a", "GIF89a"];
724
+ const WEBP_RIFF_SIGNATURE = "RIFF";
725
+ const WEBP_FILE_SIGNATURE = "WEBP";
726
+
727
+ interface PngChunk {
728
+ typeBytes: Buffer;
729
+ type: string;
730
+ data: Buffer;
731
+ }
732
+
733
+ interface PngValidationState {
734
+ sawIHDR: boolean;
735
+ sawIDAT: boolean;
736
+ sawIEND: boolean;
737
+ width: number;
738
+ height: number;
739
+ bitDepth: number;
740
+ colorType: number;
741
+ interlaceMethod: number;
742
+ compressedParts: Buffer[];
743
+ }
744
+
745
+ function createPngValidationState(): PngValidationState {
746
+ return {
747
+ sawIHDR: false,
748
+ sawIDAT: false,
749
+ sawIEND: false,
750
+ width: 0,
751
+ height: 0,
752
+ bitDepth: 0,
753
+ colorType: 0,
754
+ interlaceMethod: 0,
755
+ compressedParts: [],
756
+ };
757
+ }
758
+
759
+ function isValidPngColorFormat(bitDepth: number, colorType: number): boolean {
760
+ switch (colorType) {
761
+ case 0:
762
+ return [1, 2, 4, 8, 16].includes(bitDepth);
763
+ case 2:
764
+ case 4:
765
+ case 6:
766
+ return bitDepth === 8 || bitDepth === 16;
767
+ case 3:
768
+ return [1, 2, 4, 8].includes(bitDepth);
769
+ default:
770
+ return false;
771
+ }
772
+ }
773
+
774
+ function getPngScanlineByteLength(
775
+ width: number,
776
+ bitDepth: number,
777
+ colorType: number,
778
+ ): number {
779
+ const samplesPerPixel =
780
+ colorType === 0 || colorType === 3
781
+ ? 1
782
+ : colorType === 4
783
+ ? 2
784
+ : colorType === 2
785
+ ? 3
786
+ : 4;
787
+ return Math.ceil((width * bitDepth * samplesPerPixel) / 8);
788
+ }
789
+
790
+ function readPngChunk(
791
+ data: Buffer,
792
+ offset: number,
793
+ ): { chunk: PngChunk; nextOffset: number } | string {
794
+ if (offset + 12 > data.length) {
795
+ return "truncated PNG chunk header";
796
+ }
797
+
798
+ const length = data.readUInt32BE(offset);
799
+ const typeBytes = data.subarray(offset + 4, offset + 8);
800
+ const type = typeBytes.toString("ascii");
801
+ const dataOffset = offset + 8;
802
+ const nextOffset = dataOffset + length + 4;
803
+
804
+ if (nextOffset > data.length) {
805
+ return `truncated PNG chunk ${type}`;
806
+ }
807
+
808
+ const chunkData = data.subarray(dataOffset, dataOffset + length);
809
+ const storedCrc = data.readUInt32BE(dataOffset + length);
810
+ const computedCrc = crc32(Buffer.concat([typeBytes, chunkData]));
811
+
812
+ if (storedCrc !== computedCrc) {
813
+ return `invalid PNG CRC for chunk ${type}`;
814
+ }
815
+
816
+ return {
817
+ chunk: { typeBytes, type, data: chunkData },
818
+ nextOffset,
819
+ };
820
+ }
821
+
822
+ function validatePngHeaderChunk(
823
+ state: PngValidationState,
824
+ chunk: PngChunk,
825
+ ): string | null {
826
+ if (state.sawIHDR || state.sawIDAT || chunk.data.length !== 13) {
827
+ return "invalid IHDR chunk";
828
+ }
829
+
830
+ const compressionMethod = chunk.data[10] ?? 0;
831
+ const filterMethod = chunk.data[11] ?? 0;
832
+ const interlaceMethod = chunk.data[12] ?? 0;
833
+
834
+ state.sawIHDR = true;
835
+ state.width = chunk.data.readUInt32BE(0);
836
+ state.height = chunk.data.readUInt32BE(4);
837
+ state.bitDepth = chunk.data[8] ?? 0;
838
+ state.colorType = chunk.data[9] ?? 0;
839
+ state.interlaceMethod = interlaceMethod;
840
+
841
+ if (state.width === 0 || state.height === 0) {
842
+ return "invalid PNG image size";
843
+ }
844
+ if (!isValidPngColorFormat(state.bitDepth, state.colorType)) {
845
+ return "unsupported PNG color format";
846
+ }
847
+ if (compressionMethod !== 0 || filterMethod !== 0) {
848
+ return "unsupported PNG header values";
849
+ }
850
+ if (interlaceMethod !== 0 && interlaceMethod !== 1) {
851
+ return "unsupported PNG interlace method";
852
+ }
853
+
854
+ return null;
855
+ }
856
+
857
+ function applyPngChunk(
858
+ state: PngValidationState,
859
+ chunk: PngChunk,
860
+ nextOffset: number,
861
+ totalLength: number,
862
+ ): string | null {
863
+ if (chunk.type === "IHDR") {
864
+ return validatePngHeaderChunk(state, chunk);
865
+ }
866
+
867
+ if (!state.sawIHDR) {
868
+ return "missing IHDR chunk";
869
+ }
870
+
871
+ if (chunk.type === "IDAT") {
872
+ if (state.sawIEND) {
873
+ return "invalid PNG chunk order";
874
+ }
875
+ state.sawIDAT = true;
876
+ state.compressedParts.push(chunk.data);
877
+ return null;
878
+ }
879
+
880
+ if (chunk.type !== "IEND") {
881
+ return null;
882
+ }
883
+
884
+ if (!state.sawIDAT) {
885
+ return "missing IDAT chunk";
886
+ }
887
+ if (chunk.data.length !== 0) {
888
+ return "invalid IEND chunk length";
889
+ }
890
+
891
+ state.sawIEND = true;
892
+ if (nextOffset !== totalLength) {
893
+ return "unexpected trailing data after IEND chunk";
894
+ }
895
+
896
+ return null;
897
+ }
898
+
899
+ function validateInflatedPngData(state: PngValidationState): string | null {
900
+ try {
901
+ const inflated = inflateSync(Buffer.concat(state.compressedParts));
902
+ if (state.interlaceMethod !== 0) {
903
+ return null;
904
+ }
905
+
906
+ const expectedLength =
907
+ state.height *
908
+ (1 +
909
+ getPngScanlineByteLength(state.width, state.bitDepth, state.colorType));
910
+ if (inflated.length !== expectedLength) {
911
+ return "decoded PNG payload does not match image dimensions";
912
+ }
913
+ return null;
914
+ } catch (error) {
915
+ const message = error instanceof Error ? error.message : String(error);
916
+ return `corrupt PNG image data: ${message}`;
917
+ }
918
+ }
919
+
920
+ function validatePngImageData(data: Buffer): string | null {
921
+ if (
922
+ data.length < PNG_SIGNATURE.length ||
923
+ !data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
924
+ ) {
925
+ return "invalid PNG signature";
926
+ }
927
+
928
+ const state = createPngValidationState();
929
+ let offset = PNG_SIGNATURE.length;
930
+
931
+ while (offset < data.length) {
932
+ const parsedChunk = readPngChunk(data, offset);
933
+ if (typeof parsedChunk === "string") {
934
+ return parsedChunk;
935
+ }
936
+
937
+ const chunkError = applyPngChunk(
938
+ state,
939
+ parsedChunk.chunk,
940
+ parsedChunk.nextOffset,
941
+ data.length,
942
+ );
943
+ if (chunkError) {
944
+ return chunkError;
945
+ }
946
+
947
+ offset = parsedChunk.nextOffset;
948
+ }
949
+
950
+ if (!state.sawIHDR) {
951
+ return "missing IHDR chunk";
952
+ }
953
+ if (!state.sawIDAT) {
954
+ return "missing IDAT chunk";
955
+ }
956
+ if (!state.sawIEND) {
957
+ return "missing IEND chunk";
958
+ }
959
+
960
+ return validateInflatedPngData(state);
961
+ }
962
+
963
+ function validateJpegImageData(data: Buffer): string | null {
964
+ if (
965
+ data.length < 4 ||
966
+ !data.subarray(0, JPEG_SIGNATURE.length).equals(JPEG_SIGNATURE) ||
967
+ data.at(-2) !== 0xff ||
968
+ data.at(-1) !== 0xd9
969
+ ) {
970
+ return "invalid JPEG markers";
971
+ }
972
+ return null;
973
+ }
974
+
975
+ function validateGifImageData(data: Buffer): string | null {
976
+ if (data.length < 14) {
977
+ return "truncated GIF file";
978
+ }
979
+
980
+ const header = data.subarray(0, 6).toString("ascii");
981
+ if (!GIF_SIGNATURES.includes(header)) {
982
+ return "invalid GIF signature";
983
+ }
984
+ if (data.at(-1) !== 0x3b) {
985
+ return "missing GIF trailer";
986
+ }
987
+ return null;
988
+ }
989
+
990
+ function validateWebpImageData(data: Buffer): string | null {
991
+ if (data.length < 16) {
992
+ return "truncated WebP file";
993
+ }
994
+
995
+ if (
996
+ data.subarray(0, 4).toString("ascii") !== WEBP_RIFF_SIGNATURE ||
997
+ data.subarray(8, 12).toString("ascii") !== WEBP_FILE_SIGNATURE
998
+ ) {
999
+ return "invalid WebP signature";
1000
+ }
1001
+
1002
+ const riffSize = data.readUInt32LE(4);
1003
+ if (riffSize + 8 > data.length) {
1004
+ return "truncated WebP file";
1005
+ }
1006
+
1007
+ return null;
1008
+ }
1009
+
1010
+ function validateImageData(mimeType: string, data: Buffer): string | null {
1011
+ switch (mimeType) {
1012
+ case "image/png":
1013
+ return validatePngImageData(data);
1014
+ case "image/jpeg":
1015
+ return validateJpegImageData(data);
1016
+ case "image/gif":
1017
+ return validateGifImageData(data);
1018
+ case "image/webp":
1019
+ return validateWebpImageData(data);
1020
+ default:
1021
+ return `unsupported image MIME type ${mimeType}`;
1022
+ }
1023
+ }
1024
+
702
1025
  const readImageToolParameters = Type.Object({
703
1026
  path: Type.String({
704
1027
  description: "File path (absolute or relative to cwd)",
@@ -740,10 +1063,16 @@ export function executeReadImage(
740
1063
 
741
1064
  try {
742
1065
  const data = readFileSync(filePath);
743
- const base64 = Buffer.from(data).toString("base64");
1066
+ const validationError = validateImageData(mimeType, data);
1067
+ if (validationError) {
1068
+ return textResult(
1069
+ `Invalid image file ${args.path}: ${validationError}`,
1070
+ true,
1071
+ );
1072
+ }
744
1073
 
745
1074
  return {
746
- content: [{ type: "image", data: base64, mimeType }],
1075
+ content: [{ type: "image", data: data.toString("base64"), mimeType }],
747
1076
  isError: false,
748
1077
  };
749
1078
  } catch (error) {
package/src/ui/agent.ts CHANGED
@@ -46,6 +46,8 @@ interface UiAgentRuntime {
46
46
  requestRender: (priority?: UiRenderPriority) => void;
47
47
  /** Re-enable stick-to-bottom behavior for the conversation log. */
48
48
  scrollConversationToBottom: () => void;
49
+ /** Clear the readonly queued-input draft after it is committed. */
50
+ clearQueuedInputDraft: () => void;
49
51
  /** Start the active-turn divider animation. */
50
52
  startDividerAnimation: () => void;
51
53
  /** Stop the active-turn divider animation. */
@@ -116,12 +118,14 @@ export function createUiAgentController(
116
118
  if (
117
119
  pending.toolName === event.name &&
118
120
  pending.content === event.result.content &&
121
+ pending.details === event.result.details &&
119
122
  pending.isError === event.result.isError
120
123
  ) {
121
124
  return false;
122
125
  }
123
126
  pending.toolName = event.name;
124
127
  pending.content = event.result.content;
128
+ pending.details = event.result.details;
125
129
  pending.isError = event.result.isError;
126
130
  return true;
127
131
  }
@@ -130,6 +134,7 @@ export function createUiAgentController(
130
134
  toolCallId: event.toolCallId,
131
135
  toolName: event.name,
132
136
  content: event.result.content,
137
+ details: event.result.details,
133
138
  isError: event.result.isError,
134
139
  });
135
140
  return true;
@@ -195,6 +200,7 @@ export function createUiAgentController(
195
200
  ): void => {
196
201
  switch (event.type) {
197
202
  case "user_message":
203
+ runtime.clearQueuedInputDraft();
198
204
  runtime.scrollConversationToBottom();
199
205
  runtime.requestRender("normal");
200
206
  return;
@@ -215,6 +221,9 @@ export function createUiAgentController(
215
221
  runtime.requestRender("normal");
216
222
  }
217
223
  return;
224
+ case "context_compacted":
225
+ runtime.requestRender("normal");
226
+ return;
218
227
  case "tool_start":
219
228
  return;
220
229
  }
@@ -280,6 +289,7 @@ export function createUiAgentController(
280
289
  onEvent: (event, currentState) => handleAgentEvent(event, currentState),
281
290
  onTurnEnd: () => {
282
291
  resetStreamingState();
292
+ runtime.clearQueuedInputDraft();
283
293
  runtime.stopDividerAnimation();
284
294
  runtime.requestRender("normal");
285
295
  },