@gajae-code/coding-agent 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/types/capability/mcp.d.ts +3 -1
  3. package/dist/types/commands/mcp.d.ts +1 -1
  4. package/dist/types/modes/components/status-line/types.d.ts +6 -0
  5. package/dist/types/modes/components/status-line.d.ts +1 -0
  6. package/dist/types/modes/utils/ui-helpers.d.ts +12 -0
  7. package/dist/types/runtime-mcp/config-writer.d.ts +4 -3
  8. package/dist/types/runtime-mcp/types.d.ts +5 -4
  9. package/dist/types/session/agent-session.d.ts +16 -0
  10. package/dist/types/session/session-manager.d.ts +1 -0
  11. package/dist/types/tools/skill.d.ts +9 -8
  12. package/dist/types/utils/linkedom.d.ts +4 -0
  13. package/dist/types/utils/pasted-image-path.d.ts +5 -3
  14. package/package.json +11 -7
  15. package/scripts/build-binary.ts +2 -40
  16. package/scripts/compile-args.ts +91 -0
  17. package/scripts/generate-docs-index.ts +1 -0
  18. package/src/capability/mcp.ts +3 -1
  19. package/src/cli/mcp-cli.ts +108 -26
  20. package/src/cli.ts +21 -0
  21. package/src/commands/mcp.ts +8 -8
  22. package/src/commands/stats.ts +6 -2
  23. package/src/config/mcp-schema.json +21 -17
  24. package/src/coordinator-mcp/server.ts +7 -4
  25. package/src/discovery/builtin.ts +14 -0
  26. package/src/discovery/mcp-json.ts +14 -0
  27. package/src/gjc-runtime/state-runtime.ts +105 -12
  28. package/src/gjc-runtime/workflow-manifest.generated.json +4 -28
  29. package/src/gjc-runtime/workflow-manifest.ts +1 -7
  30. package/src/internal-urls/docs-index.generated.ts +3 -2
  31. package/src/internal-urls/gjc-protocol.ts +14 -1
  32. package/src/modes/components/assistant-message.ts +1 -1
  33. package/src/modes/components/status-line/presets.ts +5 -7
  34. package/src/modes/components/status-line/segments.ts +14 -0
  35. package/src/modes/components/status-line/types.ts +6 -0
  36. package/src/modes/components/status-line.ts +7 -1
  37. package/src/modes/components/tool-execution.ts +1 -1
  38. package/src/modes/controllers/event-controller.ts +9 -9
  39. package/src/modes/controllers/input-controller.ts +2 -2
  40. package/src/modes/interactive-mode.ts +12 -4
  41. package/src/modes/utils/ui-helpers.ts +154 -32
  42. package/src/prompts/tools/skill.md +2 -2
  43. package/src/runtime-mcp/config-writer.ts +4 -3
  44. package/src/runtime-mcp/config.ts +1 -0
  45. package/src/runtime-mcp/types.ts +5 -4
  46. package/src/session/agent-session.ts +117 -3
  47. package/src/session/session-manager.ts +68 -40
  48. package/src/tools/bash-interactive.ts +9 -4
  49. package/src/tools/browser/readable.ts +2 -2
  50. package/src/tools/fetch.ts +5 -5
  51. package/src/tools/skill.ts +14 -11
  52. package/src/utils/linkedom.ts +14 -0
  53. package/src/utils/pasted-image-path.ts +23 -9
  54. package/src/web/scrapers/arxiv.ts +2 -2
  55. package/src/web/scrapers/go-pkg.ts +2 -2
  56. package/src/web/scrapers/iacr.ts +2 -2
  57. package/src/web/scrapers/readthedocs.ts +2 -2
  58. package/src/web/scrapers/twitter.ts +3 -2
  59. package/src/web/scrapers/wikipedia.ts +2 -2
  60. package/src/export/html/template.css +0 -1060
  61. package/src/export/html/template.html +0 -47
  62. package/src/export/html/template.js +0 -2314
  63. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  64. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  65. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  66. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  67. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  68. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  69. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
@@ -26,6 +26,7 @@ import {
26
26
  type AgentMessage,
27
27
  type AgentState,
28
28
  type AgentTool,
29
+ assertImagePlaceholdersHavePayload,
29
30
  resolveTelemetry,
30
31
  type StablePrefixSnapshot,
31
32
  ThinkingLevel,
@@ -151,6 +152,7 @@ import { onAppendOnlyModeChanged } from "../config/settings";
151
152
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
152
153
  import { loadCapability } from "../discovery";
153
154
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
155
+ import { MAX_EDIT_FILE_BYTES } from "../edit/read-file";
154
156
  import { disposeVmContextsByOwner } from "../eval/js/context-manager";
155
157
  import {
156
158
  disposeKernelSessionsByOwner,
@@ -360,6 +362,11 @@ export interface AsyncJobSnapshot {
360
362
  // Types
361
363
  // ============================================================================
362
364
 
365
+ export interface RetainedMemorySample {
366
+ tuiChatChildren?: number;
367
+ tuiCachedRenderBytes?: number;
368
+ }
369
+
363
370
  export interface AgentSessionConfig {
364
371
  agent: Agent;
365
372
  sessionManager: SessionManager;
@@ -439,6 +446,8 @@ export interface AgentSessionConfig {
439
446
  * **MUST NOT** dispose it on their own teardown.
440
447
  */
441
448
  ownedAsyncJobManager?: AsyncJobManager;
449
+ /** Cheap TUI retained-memory counters; absent for headless sessions. */
450
+ retainedMemorySampler?: () => RetainedMemorySample;
442
451
  /**
443
452
  * MCPManager whose lifecycle this session owns (top-level sessions that
444
453
  * connected plugin-bundle MCP servers). Only the owned manager is
@@ -1084,6 +1093,68 @@ export function buildContextInjectionSignature(kind: string, parts: readonly str
1084
1093
  }
1085
1094
  return hash.digest("base64url");
1086
1095
  }
1096
+
1097
+ const STREAMING_EDIT_FILE_CACHE_MAX_ENTRIES = 16;
1098
+ const STREAMING_EDIT_FILE_CACHE_MAX_TOTAL_BYTES = 32 * 1024 * 1024;
1099
+
1100
+ type StreamingEditFileCacheEntry = {
1101
+ content: string;
1102
+ bytes: number;
1103
+ };
1104
+
1105
+ export class StreamingEditFileCache {
1106
+ #entries = new Map<string, StreamingEditFileCacheEntry>();
1107
+ #totalBytes = 0;
1108
+
1109
+ get(path: string): string | undefined {
1110
+ const entry = this.#entries.get(path);
1111
+ if (entry === undefined) return undefined;
1112
+ this.#entries.delete(path);
1113
+ this.#entries.set(path, entry);
1114
+ return entry.content;
1115
+ }
1116
+
1117
+ set(path: string, content: string): void {
1118
+ const bytes = Buffer.byteLength(content, "utf8");
1119
+ if (bytes > MAX_EDIT_FILE_BYTES || bytes > STREAMING_EDIT_FILE_CACHE_MAX_TOTAL_BYTES) {
1120
+ this.delete(path);
1121
+ return;
1122
+ }
1123
+
1124
+ this.delete(path);
1125
+ while (
1126
+ this.#entries.size >= STREAMING_EDIT_FILE_CACHE_MAX_ENTRIES ||
1127
+ this.#totalBytes + bytes > STREAMING_EDIT_FILE_CACHE_MAX_TOTAL_BYTES
1128
+ ) {
1129
+ const oldestPath = this.#entries.keys().next().value;
1130
+ if (oldestPath === undefined) break;
1131
+ this.delete(oldestPath);
1132
+ }
1133
+
1134
+ this.#entries.set(path, { content, bytes });
1135
+ this.#totalBytes += bytes;
1136
+ }
1137
+
1138
+ delete(path: string): void {
1139
+ const entry = this.#entries.get(path);
1140
+ if (entry === undefined) return;
1141
+ this.#entries.delete(path);
1142
+ this.#totalBytes -= entry.bytes;
1143
+ }
1144
+
1145
+ clear(): void {
1146
+ this.#entries.clear();
1147
+ this.#totalBytes = 0;
1148
+ }
1149
+
1150
+ has(path: string): boolean {
1151
+ return this.#entries.has(path);
1152
+ }
1153
+
1154
+ get totalBytes(): number {
1155
+ return this.#totalBytes;
1156
+ }
1157
+ }
1087
1158
  export class AgentSession {
1088
1159
  readonly agent: Agent;
1089
1160
  readonly sessionManager: SessionManager;
@@ -1148,6 +1219,7 @@ export class AgentSession {
1148
1219
  #compactionAbortController: AbortController | undefined = undefined;
1149
1220
  #autoCompactionAbortController: AbortController | undefined = undefined;
1150
1221
  #resourceSampler: () => EmergencyCompactionSample = () => this.#defaultResourceSample();
1222
+ #retainedMemorySampler: (() => RetainedMemorySample) | undefined;
1151
1223
  #prePromptContextCheckPromise: Promise<void> | undefined = undefined;
1152
1224
 
1153
1225
  // Branch summarization state
@@ -1321,7 +1393,7 @@ export class AgentSession {
1321
1393
 
1322
1394
  #streamingEditPrecheckedToolCallIds = new Set<string>();
1323
1395
 
1324
- #streamingEditFileCache = new Map<string, string>();
1396
+ #streamingEditFileCache = new StreamingEditFileCache();
1325
1397
  readonly streamingEditDebugCounters = {
1326
1398
  guardRuns: 0,
1327
1399
  processedChars: 0,
@@ -1447,6 +1519,7 @@ export class AgentSession {
1447
1519
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
1448
1520
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
1449
1521
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
1522
+ this.#retainedMemorySampler = config.retainedMemorySampler;
1450
1523
  this.#ownedMcpManager = config.ownedMcpManager;
1451
1524
  this.#scopedModels = config.scopedModels ?? [];
1452
1525
  this.#thinkingLevel = config.thinkingLevel;
@@ -3170,7 +3243,31 @@ export class AgentSession {
3170
3243
  // which is the optional patch-preview verification gated by
3171
3244
  // edit.streamingAbort. Skip the read when the setting is off.
3172
3245
  if (this.settings.get("edit.streamingAbort")) {
3173
- this.#ensureFileCache(streamingEdit.resolvedPath);
3246
+ // Fire-and-forget async priming: toolcall deltas arrive on the hot
3247
+ // stream path, so never block it on filesystem I/O. The abort-check
3248
+ // path (#maybeAbortStreamingEdit) still falls back to the sync read
3249
+ // if priming hasn't completed by the time removed lines appear.
3250
+ void this.#preCacheFileAsync(streamingEdit.resolvedPath);
3251
+ }
3252
+ }
3253
+
3254
+ #streamingEditPrecachePending = new Set<string>();
3255
+ async #preCacheFileAsync(resolvedPath: string): Promise<void> {
3256
+ if (this.#streamingEditFileCache.has(resolvedPath)) return;
3257
+ if (this.#streamingEditPrecachePending.has(resolvedPath)) return;
3258
+ this.#streamingEditPrecachePending.add(resolvedPath);
3259
+ try {
3260
+ const stat = await fs.promises.stat(resolvedPath);
3261
+ if (stat.size > MAX_EDIT_FILE_BYTES) return;
3262
+
3263
+ const rawText = await fs.promises.readFile(resolvedPath, "utf-8");
3264
+ if (this.#streamingEditFileCache.has(resolvedPath)) return;
3265
+ const { text } = stripBom(rawText);
3266
+ this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
3267
+ } catch {
3268
+ // Don't cache on read errors (including ENOENT) - let the edit tool handle them
3269
+ } finally {
3270
+ this.#streamingEditPrecachePending.delete(resolvedPath);
3174
3271
  }
3175
3272
  }
3176
3273
 
@@ -3178,6 +3275,9 @@ export class AgentSession {
3178
3275
  if (this.#streamingEditFileCache.has(resolvedPath)) return;
3179
3276
 
3180
3277
  try {
3278
+ const stat = fs.statSync(resolvedPath);
3279
+ if (stat.size > MAX_EDIT_FILE_BYTES) return;
3280
+
3181
3281
  const rawText = fs.readFileSync(resolvedPath, "utf-8");
3182
3282
  const { text } = stripBom(rawText);
3183
3283
  this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
@@ -5298,6 +5398,7 @@ export class AgentSession {
5298
5398
 
5299
5399
  // Expand file-based prompt templates if requested
5300
5400
  const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
5401
+ assertImagePlaceholdersHavePayload(expandedText, options?.images);
5301
5402
  const workflowIntentDiff = options?.synthetic ? null : buildWorkflowIntentDiff(expandedText);
5302
5403
 
5303
5404
  // If streaming, queue via steer() or followUp() based on option
@@ -5784,6 +5885,7 @@ export class AgentSession {
5784
5885
  }
5785
5886
 
5786
5887
  const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
5888
+ assertImagePlaceholdersHavePayload(expandedText, images);
5787
5889
  await this.#queueSteer(expandedText, images);
5788
5890
  }
5789
5891
 
@@ -5800,6 +5902,7 @@ export class AgentSession {
5800
5902
  }
5801
5903
 
5802
5904
  const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
5905
+ assertImagePlaceholdersHavePayload(expandedText, images);
5803
5906
  await this.#queueFollowUp(expandedText, images, {
5804
5907
  forceOneAtATime: options?.followUpQueuePolicy === "sequential",
5805
5908
  });
@@ -5809,6 +5912,7 @@ export class AgentSession {
5809
5912
  * Internal: Queue a steering message (already expanded, no extension command check).
5810
5913
  */
5811
5914
  async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
5915
+ assertImagePlaceholdersHavePayload(text, images);
5812
5916
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
5813
5917
  this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText));
5814
5918
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
@@ -5840,6 +5944,7 @@ export class AgentSession {
5840
5944
  * Internal: Queue a follow-up message (already expanded, no extension command check).
5841
5945
  */
5842
5946
  async #queueFollowUp(text: string, images?: ImageContent[], options?: { forceOneAtATime?: boolean }): Promise<void> {
5947
+ assertImagePlaceholdersHavePayload(text, images);
5843
5948
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
5844
5949
  this.#followUpMessages.push(this.#createQueuedDisplayEntry(displayText));
5845
5950
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
@@ -7732,9 +7837,14 @@ export class AgentSession {
7732
7837
  this.#resourceSampler = sampler;
7733
7838
  }
7734
7839
 
7840
+ setRetainedMemorySampler(sampler: (() => RetainedMemorySample) | undefined): void {
7841
+ this.#retainedMemorySampler = sampler;
7842
+ }
7843
+
7735
7844
  #defaultResourceSample(): EmergencyCompactionSample {
7736
7845
  let providerBytes = 0;
7737
7846
  let imageBytes = 0;
7847
+ const retainedMemory = this.#retainedMemorySampler?.() ?? {};
7738
7848
  for (const message of this.state.messages) {
7739
7849
  const content = (message as { content?: unknown }).content;
7740
7850
  if (typeof content === "string") {
@@ -7756,6 +7866,10 @@ export class AgentSession {
7756
7866
  providerBytes,
7757
7867
  messageCount: this.state.messages.length,
7758
7868
  imageBytes,
7869
+ sessionResidentImageBytes: this.sessionManager.getResidentImageBytes(),
7870
+ materializedResidentBytes: this.#streamingEditFileCache.totalBytes,
7871
+ tuiChatChildren: retainedMemory.tuiChatChildren ?? 0,
7872
+ tuiCachedRenderBytes: retainedMemory.tuiCachedRenderBytes ?? 0,
7759
7873
  };
7760
7874
  }
7761
7875
 
@@ -8071,7 +8185,7 @@ export class AgentSession {
8071
8185
  attribution: "agent",
8072
8186
  timestamp: Date.now(),
8073
8187
  });
8074
- this.#scheduleAgentContinue({ generation: this.#promptGeneration });
8188
+ this.#scheduleAgentContinue({ skipCompactionCheck: true });
8075
8189
  }
8076
8190
 
8077
8191
  /**
@@ -45,8 +45,6 @@ import {
45
45
  MemoryBlobStore,
46
46
  parseBlobRef,
47
47
  ResidentBlobMissingError,
48
- resolveImageData,
49
- resolveImageDataUrl,
50
48
  resolveResidentImageDataSync,
51
49
  resolveResidentImageDataUrlSync,
52
50
  resolveTextBlobSync,
@@ -973,47 +971,46 @@ export async function loadEntriesFromFile(
973
971
  }
974
972
 
975
973
  /**
976
- * Resolve blob references in loaded entries, restoring both session image blocks and persisted
977
- * provider image URLs back to the inline data expected by downstream transports. Mutates entries in place.
974
+ * Convert legacy persisted blob references in loaded entries into resident sentinels.
975
+ * Images then materialize lazily at provider/display/export chokepoints instead of
976
+ * pinning every historical base64 string for the lifetime of a resumed session.
978
977
  */
979
978
  function hasImageUrl(value: unknown): value is { image_url: string | { url?: string } } {
980
979
  return typeof value === "object" && value !== null && "image_url" in value;
981
980
  }
982
981
 
983
- async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, key?: string): Promise<void> {
982
+ function residentizePersistedBlobRefs(value: unknown, key?: string): void {
984
983
  if (Array.isArray(value)) {
985
- await Promise.all(value.map(item => resolvePersistedBlobRefs(item, blobStore, key)));
984
+ for (const item of value) residentizePersistedBlobRefs(item, key);
986
985
  return;
987
986
  }
988
987
 
989
988
  if (typeof value !== "object" || value === null) return;
990
989
 
991
990
  if (isImageBlock(value) && isBlobRef(value.data)) {
992
- value.data = await resolveImageData(blobStore, value.data);
991
+ value.data = residentBlobSentinel("imageData", value.data) as unknown as string;
993
992
  }
994
993
 
995
994
  if (hasImageUrl(value)) {
996
995
  if (typeof value.image_url === "string" && isBlobRef(value.image_url)) {
997
- value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
996
+ value.image_url = residentBlobSentinel("imageUrl", value.image_url) as unknown as string;
998
997
  } else if (
999
998
  typeof value.image_url === "object" &&
1000
999
  value.image_url !== null &&
1001
1000
  typeof value.image_url.url === "string" &&
1002
1001
  isBlobRef(value.image_url.url)
1003
1002
  ) {
1004
- value.image_url.url = await resolveImageDataUrl(blobStore, value.image_url.url);
1003
+ value.image_url.url = residentBlobSentinel("imageUrl", value.image_url.url) as unknown as string;
1005
1004
  }
1006
1005
  }
1007
1006
 
1008
- await Promise.all(
1009
- Object.entries(value).map(async ([childKey, item]) => {
1010
- if (childKey === "data" && typeof item === "string" && isBlobRef(item) && key !== TEXT_CONTENT_KEY) {
1011
- (value as Record<string, unknown>)[childKey] = await resolveImageDataUrl(blobStore, item);
1012
- return;
1013
- }
1014
- await resolvePersistedBlobRefs(item, blobStore, childKey);
1015
- }),
1016
- );
1007
+ for (const [childKey, item] of Object.entries(value)) {
1008
+ if (childKey === "data" && typeof item === "string" && isBlobRef(item) && key !== TEXT_CONTENT_KEY) {
1009
+ (value as Record<string, unknown>)[childKey] = residentBlobSentinel("imageUrl", item);
1010
+ continue;
1011
+ }
1012
+ residentizePersistedBlobRefs(item, childKey);
1013
+ }
1017
1014
  }
1018
1015
 
1019
1016
  /**
@@ -1035,28 +1032,13 @@ async function runWithConcurrency(tasks: Array<() => Promise<void>>, limit: numb
1035
1032
  await Promise.all(Array.from({ length: workerCount }, () => worker()));
1036
1033
  }
1037
1034
 
1038
- async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
1035
+ async function resolveBlobRefsInEntries(entries: FileEntry[], _blobStore: BlobStore): Promise<void> {
1039
1036
  const tasks: Array<() => Promise<void>> = [];
1040
1037
 
1041
1038
  for (const entry of entries) {
1042
1039
  if (entry.type === "session") continue;
1043
-
1044
- let contentArray: unknown[] | undefined;
1045
- if (entry.type === "message" && "content" in entry.message && Array.isArray(entry.message.content)) {
1046
- contentArray = entry.message.content;
1047
- } else if (entry.type === "custom_message" && Array.isArray(entry.content)) {
1048
- contentArray = entry.content;
1049
- }
1050
-
1051
1040
  tasks.push(async () => {
1052
- if (contentArray) {
1053
- for (const block of contentArray) {
1054
- if (isImageBlock(block) && isBlobRef(block.data)) {
1055
- block.data = await resolveImageData(blobStore, block.data);
1056
- }
1057
- }
1058
- }
1059
- await resolvePersistedBlobRefs(entry, blobStore);
1041
+ residentizePersistedBlobRefs(entry);
1060
1042
  });
1061
1043
  }
1062
1044
 
@@ -1314,6 +1296,33 @@ function containsResidentSentinel(value: unknown, seen = new WeakSet<object>()):
1314
1296
  return false;
1315
1297
  }
1316
1298
 
1299
+ function containsResidentImageSentinel(value: unknown, seen = new WeakSet<object>()): boolean {
1300
+ if (value === null || value === undefined || typeof value !== "object") return false;
1301
+ if (isResidentBlobSentinel(value)) return value.kind === "imageUrl" || value.kind === "imageData";
1302
+ if (seen.has(value)) return false;
1303
+ seen.add(value);
1304
+ if (Array.isArray(value)) return value.some(item => containsResidentImageSentinel(item, seen));
1305
+ for (const child of Object.values(value)) {
1306
+ if (containsResidentImageSentinel(child, seen)) return true;
1307
+ }
1308
+ return false;
1309
+ }
1310
+
1311
+ function collectResidentImageRefs(value: unknown, refs: Set<string>, seen = new WeakSet<object>()): void {
1312
+ if (value === null || value === undefined || typeof value !== "object") return;
1313
+ if (isResidentBlobSentinel(value)) {
1314
+ if (value.kind === "imageUrl" || value.kind === "imageData") refs.add(value.ref);
1315
+ return;
1316
+ }
1317
+ if (seen.has(value)) return;
1318
+ seen.add(value);
1319
+ if (Array.isArray(value)) {
1320
+ for (const item of value) collectResidentImageRefs(item, refs, seen);
1321
+ return;
1322
+ }
1323
+ for (const child of Object.values(value)) collectResidentImageRefs(child, refs, seen);
1324
+ }
1325
+
1317
1326
  /**
1318
1327
  * Recursively truncate large strings in an object for session persistence.
1319
1328
  * - Truncates any oversized string fields (key-agnostic)
@@ -4148,6 +4157,22 @@ export class SessionManager {
4148
4157
  return entry ? materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()) : undefined;
4149
4158
  }
4150
4159
 
4160
+ getResidentImageBytes(): number {
4161
+ const refs = new Set<string>();
4162
+ for (const entry of this.#fileEntries) collectResidentImageRefs(entry, refs);
4163
+ let bytes = 0;
4164
+ for (const ref of refs) {
4165
+ const hash = parseBlobRef(ref);
4166
+ if (!hash) continue;
4167
+ try {
4168
+ bytes += fs.statSync(path.join(this.#residentImageBlobStore.dir, hash)).size;
4169
+ } catch (err) {
4170
+ if (!isEnoent(err)) throw err;
4171
+ }
4172
+ }
4173
+ return bytes;
4174
+ }
4175
+
4151
4176
  /**
4152
4177
  * Get the most recent model role from the current session path.
4153
4178
  * Returns undefined if no model change has been recorded.
@@ -4571,11 +4596,14 @@ export class SessionManager {
4571
4596
  }
4572
4597
  this.#materializedEntriesCachePopulateCount++;
4573
4598
  const resolvedTextBlobCache = new Map<string, string>();
4574
- const materializedEntries = this.#fileEntries
4575
- .filter((e): e is SessionEntry => e.type !== "session")
4576
- .map(entry => materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), resolvedTextBlobCache));
4577
- this.#materializedEntriesCache = materializedEntries;
4578
- this.#materializedEntriesRevision = this.#entryRevision;
4599
+ const sourceEntries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
4600
+ const materializedEntries = sourceEntries.map(entry =>
4601
+ materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), resolvedTextBlobCache),
4602
+ );
4603
+ if (!sourceEntries.some(entry => containsResidentImageSentinel(entry))) {
4604
+ this.#materializedEntriesCache = materializedEntries;
4605
+ this.#materializedEntriesRevision = this.#entryRevision;
4606
+ }
4579
4607
  return materializedEntries;
4580
4608
  }
4581
4609
 
@@ -12,7 +12,6 @@ import {
12
12
  } from "@gajae-code/tui";
13
13
  import { sanitizeText } from "@gajae-code/utils";
14
14
  import type { Terminal as XtermTerminalType } from "@xterm/headless";
15
- import xterm from "@xterm/headless";
16
15
  import { Settings } from "../config/settings";
17
16
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
18
17
  import type { Theme } from "../modes/theme/theme";
@@ -32,8 +31,6 @@ function normalizeCaptureChunk(chunk: string): string {
32
31
  return sanitizeWithOptionalSixelPassthrough(normalized, sanitizeText);
33
32
  }
34
33
 
35
- const XtermTerminal = xterm.Terminal;
36
-
37
34
  function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean): string {
38
35
  const kitty = parseKittySequence(data);
39
36
  if (kitty?.eventType === 3) {
@@ -113,6 +110,7 @@ class BashInteractiveOverlayComponent implements Component {
113
110
  private readonly command: string,
114
111
  private readonly uiTheme: Theme,
115
112
  private readonly getTerminalRows: () => number,
113
+ XtermTerminal: typeof XtermTerminalType,
116
114
  ) {
117
115
  this.#terminal = new XtermTerminal({
118
116
  cols: 120,
@@ -305,10 +303,17 @@ export async function runInteractiveBashPty(
305
303
  headBytes: resolveOutputSinkHeadBytes(settings),
306
304
  maxColumns: resolveOutputMaxColumns(settings),
307
305
  });
306
+ const { default: xterm } = await import("@xterm/headless");
307
+ const XtermTerminal = xterm.Terminal;
308
308
  const result = await ui.custom<BashInteractiveResult>(
309
309
  (tui, uiTheme, _keybindings, done) => {
310
310
  const session = new PtySession();
311
- const component = new BashInteractiveOverlayComponent(options.command, uiTheme, () => tui.terminal.rows);
311
+ const component = new BashInteractiveOverlayComponent(
312
+ options.command,
313
+ uiTheme,
314
+ () => tui.terminal.rows,
315
+ XtermTerminal,
316
+ );
312
317
  component.setSession(session);
313
318
  let finished = false;
314
319
  const finalize = (run: PtyRunResult) => {
@@ -1,5 +1,5 @@
1
1
  import { Readability } from "@mozilla/readability";
2
- import { parseHTML } from "linkedom";
2
+ import { parseHtmlLazy } from "../../utils/linkedom";
3
3
  import { htmlToBasicMarkdown } from "../../web/scrapers/types";
4
4
 
5
5
  export type ReadableFormat = "text" | "markdown";
@@ -31,7 +31,7 @@ export async function extractReadableFromHtml(
31
31
  url: string,
32
32
  format: ReadableFormat,
33
33
  ): Promise<ReadableResult | null> {
34
- const { document } = parseHTML(html);
34
+ const { document } = await parseHtmlLazy(html);
35
35
 
36
36
  // --- Primary: Readability article extraction ---
37
37
  const article = new Readability(document).parse();
@@ -5,7 +5,6 @@ import type { ImageContent, TextContent } from "@gajae-code/ai";
5
5
  import { htmlToMarkdown } from "@gajae-code/natives";
6
6
  import { type Component, Text } from "@gajae-code/tui";
7
7
  import { $which, ptree, truncate } from "@gajae-code/utils";
8
- import { parseHTML } from "linkedom";
9
8
  import type { Settings } from "../config/settings";
10
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
10
  import { type Theme, theme } from "../modes/theme/theme";
@@ -15,6 +14,7 @@ import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
15
14
  import { renderStatusLine } from "../tui";
16
15
  import { CachedOutputBlock } from "../tui/output-block";
17
16
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
17
+ import { parseHtmlLazy } from "../utils/linkedom";
18
18
  import { ensureTool } from "../utils/tools-manager";
19
19
  import { INSANE_NOTES, tryInsaneFetch } from "../web/insane/bridge";
20
20
  import { validatePublicHttpUrl, validatePublicHttpUrlForInsane } from "../web/insane/url-guard";
@@ -483,9 +483,9 @@ function cleanFeedText(text: string): string {
483
483
  /**
484
484
  * Parse RSS/Atom feed to markdown
485
485
  */
486
- function parseFeedToMarkdown(content: string, maxItems = 10): string {
486
+ async function parseFeedToMarkdown(content: string, maxItems = 10): Promise<string> {
487
487
  try {
488
- const doc = parseHTML(content).document;
488
+ const doc = (await parseHtmlLazy(content)).document;
489
489
 
490
490
  // Try RSS
491
491
  const channel = doc.querySelector("channel");
@@ -1026,7 +1026,7 @@ async function renderUrl(
1026
1026
  }
1027
1027
 
1028
1028
  if (isFeed || (isXml && (rawContent.includes("<rss") || rawContent.includes("<feed")))) {
1029
- const parsed = parseFeedToMarkdown(rawContent);
1029
+ const parsed = await parseFeedToMarkdown(rawContent);
1030
1030
  const output = finalizeOutput(parsed);
1031
1031
  return {
1032
1032
  url,
@@ -1119,7 +1119,7 @@ async function renderUrl(
1119
1119
  const altResult = await loadPage(resolved, { timeout, signal });
1120
1120
  if (altResult.ok && altResult.content.trim().length > 200) {
1121
1121
  notes.push(`Used feed alternate: ${resolved}`);
1122
- const parsed = parseFeedToMarkdown(altResult.content);
1122
+ const parsed = await parseFeedToMarkdown(altResult.content);
1123
1123
  const output = finalizeOutput(parsed);
1124
1124
  return {
1125
1125
  url,
@@ -4,15 +4,16 @@
4
4
  * Lets the agent hand off to another available skill in the current turn. The
5
5
  * callee's SKILL.md is dispatched through the same custom-message path used by
6
6
  * `/skill:<name>` typing, as a user-attribution message delivered same-turn
7
- * (without `deliverAs: "nextTurn"`). Before dispatch, the tool calls
8
- * `gjc state <caller> handoff --to <callee>` in-process via the state-runtime
9
- * function so caller and callee mode-states plus `skill-active-state.json`
10
- * transition atomically.
7
+ * (without `deliverAs: "nextTurn"`). When the caller is one of the canonical
8
+ * workflow skills, the tool first runs the native state handoff so workflow
9
+ * mode-state and `skill-active-state.json` transition atomically. Runtime
10
+ * project/user skills have no native workflow mode-state, so they are dispatched
11
+ * directly and tracked by the prompt observer instead.
11
12
  *
12
- * Chaining is refused unless the caller's `current_phase` is in
13
- * `{complete, completed, handoff, failed, cancelled, canceled, inactive}`. The
14
- * agent declares readiness either by writing `current_phase: "handoff"` to its
15
- * mode-state or by running the handoff verb directly.
13
+ * Canonical workflow chaining is refused unless the caller's `current_phase` is
14
+ * in `{complete, completed, handoff, failed, cancelled, canceled, inactive}`.
15
+ * The agent declares readiness either by writing `current_phase: "handoff"` to
16
+ * its mode-state or by running the handoff verb directly.
16
17
  */
17
18
 
18
19
  import type { AgentTool, AgentToolResult } from "@gajae-code/agent-core";
@@ -24,6 +25,7 @@ import { buildSkillPromptMessage } from "../extensibility/skills";
24
25
  import { runNativeStateCommand } from "../gjc-runtime/state-runtime";
25
26
  import skillDescription from "../prompts/tools/skill.md" with { type: "text" };
26
27
  import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
28
+ import { isCanonicalGjcWorkflowSkill } from "../skill-state/active-state";
27
29
  import type { ToolSession } from ".";
28
30
  import { ToolError } from "./tool-errors";
29
31
 
@@ -130,9 +132,10 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
130
132
  throw new ToolError(`skill tool: unknown skill "${requestedName}".${hint}`);
131
133
  }
132
134
 
133
- // Phase guard + atomic handoff. Only runs when transitioning between
134
- // distinct skills (same-skill recursion was already refused above).
135
- if (activeSkill) {
135
+ // Phase guard + atomic native handoff only apply to canonical workflow
136
+ // skills. Runtime project/user skills do not have a native mode-state,
137
+ // so there is no `gjc state <skill>` command to run for them.
138
+ if (activeSkill && isCanonicalGjcWorkflowSkill(activeSkill)) {
136
139
  const phase = (this.#session.getActiveSkillPhase?.() ?? "running").trim().toLowerCase();
137
140
  if (!TERMINAL_PHASES.has(phase)) {
138
141
  throw new ToolError(
@@ -0,0 +1,14 @@
1
+ type LinkedomModule = typeof import("linkedom");
2
+
3
+ type ParseHtml = LinkedomModule["parseHTML"];
4
+
5
+ let parseHtml: ParseHtml | undefined;
6
+ let linkedomImport: Promise<LinkedomModule> | undefined;
7
+
8
+ export async function parseHtmlLazy(markup: string): Promise<ReturnType<ParseHtml>> {
9
+ if (!parseHtml) {
10
+ linkedomImport ??= import("linkedom");
11
+ parseHtml = (await linkedomImport).parseHTML;
12
+ }
13
+ return parseHtml(markup);
14
+ }
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Resolve pasted editor text to an image file path.
3
3
  *
4
- * Terminals insert a shell-escaped filesystem path when a file is drag-dropped
5
- * onto them (e.g. macOS `Screenshot\ 2026-07-07\ at\ 11.06.38 PM.png`, where
6
- * the visible "space" before AM/PM is U+202F and stays unescaped). When the
7
- * entire paste is a single path to an existing image file, the interactive
8
- * editor attaches the image and inserts an `[image N]` placeholder instead of
9
- * leaving the raw path in the prompt.
4
+ * Some terminal/clipboard integrations paste a temporary filesystem path after a
5
+ * copied image is pasted into the editor (for example `/tmp/clipboard-...png`).
6
+ * Only those recognized clipboard temp files are auto-attached and replaced with
7
+ * an `[image N] source="/path"` reference so the model receives both the image
8
+ * payload and the raw temp file path. Ordinary image paths remain literal prompt
9
+ * text; users attach saved files explicitly with `@path/to/image.png`.
10
10
  */
11
11
  import * as fs from "node:fs";
12
12
  import * as os from "node:os";
@@ -14,6 +14,7 @@ import * as path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
 
16
16
  const IMAGE_FILE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
17
+ const CLIPBOARD_TEMP_BASENAME_PATTERN = /^clipboard-\d{4}-\d{2}-\d{2}-\d{6}-[A-Za-z0-9-]+\.(?:png|jpe?g|gif|webp)$/i;
17
18
 
18
19
  export interface DecodePastedPathOptions {
19
20
  /**
@@ -150,16 +151,25 @@ function hasSupportedImageMagic(filePath: string): boolean {
150
151
  }
151
152
  }
152
153
 
154
+ function isRecognizedClipboardTempPath(filePath: string): boolean {
155
+ const resolved = path.resolve(filePath);
156
+ const tempRoot = path.resolve(os.tmpdir());
157
+ if (resolved !== tempRoot && !resolved.startsWith(`${tempRoot}${path.sep}`)) return false;
158
+ return CLIPBOARD_TEMP_BASENAME_PATTERN.test(path.basename(resolved));
159
+ }
160
+
153
161
  /**
154
- * Returns the resolved path when the whole pasted text is a single path to an
155
- * existing image file (verified by extension AND content signature),
156
- * otherwise `undefined` (the paste is inserted as text).
162
+ * Returns the resolved path when the whole pasted text is a recognized clipboard
163
+ * temp path to an existing image file, otherwise `undefined` (the paste is
164
+ * inserted as text). Arbitrary saved image paths are not auto-attached here;
165
+ * users attach them explicitly with `@path/to/image.png`.
157
166
  */
158
167
  export function resolvePastedImagePath(text: string, options?: ResolvePastedImagePathOptions): string | undefined {
159
168
  const candidate = decodePastedPathCandidate(text, options);
160
169
  if (!candidate || !IMAGE_FILE_EXTENSION_PATTERN.test(candidate)) return undefined;
161
170
 
162
171
  const resolved = path.resolve(options?.cwd ?? process.cwd(), candidate);
172
+ if (!isRecognizedClipboardTempPath(resolved)) return undefined;
163
173
  try {
164
174
  if (!fs.statSync(resolved).isFile()) return undefined;
165
175
  } catch {
@@ -168,3 +178,7 @@ export function resolvePastedImagePath(text: string, options?: ResolvePastedImag
168
178
  if (!hasSupportedImageMagic(resolved)) return undefined;
169
179
  return resolved;
170
180
  }
181
+
182
+ export function formatPastedImageReference(placeholder: string, imagePath: string): string {
183
+ return `${placeholder} source=${JSON.stringify(imagePath)}`;
184
+ }