@gajae-code/coding-agent 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/cli/worktree-cli.d.ts +8 -25
  3. package/dist/types/commands/gc.d.ts +4 -0
  4. package/dist/types/commands/worktree.d.ts +7 -32
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  6. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  7. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  8. package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
  9. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  10. package/dist/types/sdk/broker/broker.d.ts +1 -0
  11. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  12. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  13. package/dist/types/sdk/broker/internal.d.ts +3 -0
  14. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  15. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  16. package/dist/types/tools/ask.d.ts +39 -12
  17. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  18. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  19. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  20. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  21. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  22. package/dist/types/tools/path-utils.d.ts +9 -14
  23. package/dist/types/tools/render-utils.d.ts +1 -8
  24. package/dist/types/tools/resource-gc.d.ts +14 -0
  25. package/dist/types/utils/image-loading.d.ts +23 -0
  26. package/dist/types/utils/image-resize.d.ts +2 -0
  27. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  28. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  29. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  30. package/dist/types/web/scrapers/types.d.ts +1 -5
  31. package/package.json +24 -8
  32. package/scripts/run-threat-model-check.ts +60 -0
  33. package/src/cli/worktree-cli.ts +9 -287
  34. package/src/cli.ts +10 -9
  35. package/src/commands/gc.ts +11 -1
  36. package/src/commands/sdk.ts +3 -2
  37. package/src/commands/worktree.ts +10 -53
  38. package/src/config/model-profiles.ts +15 -15
  39. package/src/eval/js/shared/runtime.ts +1 -1
  40. package/src/gjc-runtime/gc-render.ts +19 -1
  41. package/src/gjc-runtime/gc-runtime.ts +68 -3
  42. package/src/gjc-runtime/state-argv.ts +292 -0
  43. package/src/gjc-runtime/state-runtime.ts +42 -210
  44. package/src/modes/components/custom-editor.ts +103 -32
  45. package/src/modes/components/custom-provider-wizard.ts +26 -1
  46. package/src/modes/components/session-observer-overlay.ts +10 -23
  47. package/src/modes/components/tool-transcript-format.ts +72 -0
  48. package/src/modes/components/transcript-viewer-overlay.ts +42 -2
  49. package/src/modes/controllers/input-controller.ts +98 -37
  50. package/src/modes/controllers/selector-controller.ts +1 -1
  51. package/src/modes/interactive-mode.ts +13 -2
  52. package/src/sdk/broker/broker.ts +139 -32
  53. package/src/sdk/broker/discovery.ts +125 -0
  54. package/src/sdk/broker/ensure.ts +103 -19
  55. package/src/sdk/broker/internal.ts +10 -0
  56. package/src/sdk/broker/session-index.ts +228 -15
  57. package/src/sdk/broker/state-version.ts +5 -2
  58. package/src/sdk/bus/telegram-daemon.ts +50 -10
  59. package/src/session/internal/managed-session-scope.ts +15 -1
  60. package/src/tools/ask.ts +216 -41
  61. package/src/tools/bash-allowed-prefixes.ts +79 -29
  62. package/src/tools/browser/launch.ts +3 -1
  63. package/src/tools/browser/readable.ts +1 -1
  64. package/src/tools/browser/screenshot-format.ts +34 -0
  65. package/src/tools/browser/tab-protocol.ts +3 -0
  66. package/src/tools/browser/tab-supervisor.ts +125 -122
  67. package/src/tools/browser/tab-worker-entry.ts +33 -14
  68. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  69. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  70. package/src/tools/browser/tab-worker.ts +7 -5
  71. package/src/tools/path-utils.ts +42 -35
  72. package/src/tools/read.ts +7 -1
  73. package/src/tools/render-utils.ts +1 -27
  74. package/src/tools/resource-gc.ts +123 -34
  75. package/src/utils/image-loading.ts +85 -34
  76. package/src/utils/image-resize.ts +16 -9
  77. package/src/utils/pasted-image-loading.ts +514 -0
  78. package/src/utils/pasted-image-path.ts +223 -98
  79. package/src/web/scrapers/html-to-markdown.ts +68 -0
  80. package/src/web/scrapers/readthedocs.ts +1 -1
  81. package/src/web/scrapers/types.ts +1 -68
  82. package/src/web/search/providers/openai-compatible.ts +76 -23
package/src/tools/ask.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
19
+ import type { RawArgumentValidationResult } from "@gajae-code/ai/types";
19
20
  import {
20
21
  type Component,
21
22
  Container,
@@ -109,36 +110,40 @@ const DeepInterviewIntentReview = z
109
110
  .strict();
110
111
 
111
112
  /** Optional structured deep-interview round metadata; when present the round is recorded automatically. */
112
- const DeepInterviewMeta = z
113
- .object({
114
- round_id: z.string().max(128).describe("stable optional round identity").optional(),
115
- round: z.number().int().nonnegative().describe("round number"),
116
- component: z.string().min(1).max(128).describe("targeted topology component"),
117
- dimension: z.string().min(1).max(128).describe("targeted clarity dimension"),
118
- ambiguity: z.number().min(0).max(1).describe("ambiguity at ask time (0..1)"),
119
- intent_contract: DeepInterviewIntentContract.describe("Round-0 locked intent contract").optional(),
120
- intent_review: DeepInterviewIntentReview.describe("Locked-intent reduction review").optional(),
121
- })
122
- .strict()
123
- .superRefine((value, context) => {
124
- if (
125
- value.intent_contract &&
126
- (value.round !== 0 || value.component !== "review-topology" || value.dimension !== "topology")
127
- )
128
- context.addIssue({
129
- code: "custom",
130
- message: "intent_contract requires Round 0 review-topology metadata",
131
- path: ["intent_contract"],
132
- });
133
- if (value.intent_review && value.round === 0)
134
- context.addIssue({
135
- code: "custom",
136
- message: "intent_review requires a post-Round-0 answer",
137
- path: ["intent_review"],
138
- });
139
- if (value.intent_contract && value.intent_review)
140
- context.addIssue({ code: "custom", message: "intent_contract and intent_review are mutually exclusive" });
141
- });
113
+ const DeepInterviewMetadata = z.object({
114
+ round_id: z.string().max(128).describe("stable optional round identity").optional(),
115
+ round: z.number().int().nonnegative().describe("round number"),
116
+ component: z.string().min(1).max(128).describe("targeted topology component"),
117
+ dimension: z.string().min(1).max(128).describe("targeted clarity dimension"),
118
+ ambiguity: z.number().min(0).max(1).describe("ambiguity at ask time (0..1)"),
119
+ });
120
+
121
+ /** Provider-visible branches keep ordinary, contract, and review metadata mutually exclusive. */
122
+ const DeepInterviewMeta = z.union([
123
+ DeepInterviewMetadata.strict(),
124
+ DeepInterviewMetadata.extend({
125
+ round: z.literal(0).describe("round number"),
126
+ component: z.literal("review-topology").describe("targeted topology component"),
127
+ dimension: z.literal("topology").describe("targeted clarity dimension"),
128
+ intent_contract: DeepInterviewIntentContract.describe("Round-0 locked intent contract"),
129
+ }).strict(),
130
+ DeepInterviewMetadata.extend({
131
+ round: z.number().int().positive().describe("round number"),
132
+ intent_review: DeepInterviewIntentReview.describe("Locked-intent reduction review"),
133
+ }).strict(),
134
+ ]);
135
+
136
+ type DeepInterviewMeta = z.infer<typeof DeepInterviewMeta>;
137
+
138
+ function intentContract(
139
+ metadata: DeepInterviewMeta | undefined,
140
+ ): z.infer<typeof DeepInterviewIntentContract> | undefined {
141
+ return metadata && "intent_contract" in metadata ? metadata.intent_contract : undefined;
142
+ }
143
+
144
+ function intentReview(metadata: DeepInterviewMeta | undefined): z.infer<typeof DeepInterviewIntentReview> | undefined {
145
+ return metadata && "intent_review" in metadata ? metadata.intent_review : undefined;
146
+ }
142
147
 
143
148
  const WorkflowGateMeta = z.object({
144
149
  stage: z.enum(["deep-interview", "ralplan", "ultragoal"]).describe("workflow gate stage"),
@@ -157,23 +162,25 @@ const QuestionItem = z
157
162
  })
158
163
  .superRefine((value, context) => {
159
164
  const labels = new Set(value.options.map(option => option.label));
160
- if ((value.deepInterview?.intent_contract || value.deepInterview?.intent_review) && value.multi === true)
165
+ const contract = intentContract(value.deepInterview);
166
+ const review = intentReview(value.deepInterview);
167
+ if ((contract || review) && value.multi === true)
161
168
  context.addIssue({ code: "custom", message: "intent gates must be single-select", path: ["multi"] });
162
- const confirmationOptions = value.deepInterview?.intent_contract?.confirmation_options ?? [];
169
+ const confirmationOptions = contract?.confirmation_options ?? [];
163
170
  if (new Set(confirmationOptions).size !== confirmationOptions.length)
164
171
  context.addIssue({
165
172
  code: "custom",
166
173
  message: "intent confirmation options must be unique",
167
174
  path: ["deepInterview", "intent_contract"],
168
175
  });
169
- const approvalOptions = value.deepInterview?.intent_review?.approval_options ?? [];
176
+ const approvalOptions = review?.approval_options ?? [];
170
177
  if (new Set(approvalOptions).size !== approvalOptions.length)
171
178
  context.addIssue({
172
179
  code: "custom",
173
180
  message: "intent approval options must be unique",
174
181
  path: ["deepInterview", "intent_review"],
175
182
  });
176
- for (const label of value.deepInterview?.intent_contract?.confirmation_options ?? []) {
183
+ for (const label of confirmationOptions) {
177
184
  if (!labels.has(label))
178
185
  context.addIssue({
179
186
  code: "custom",
@@ -181,7 +188,7 @@ const QuestionItem = z
181
188
  path: ["deepInterview", "intent_contract"],
182
189
  });
183
190
  }
184
- for (const label of value.deepInterview?.intent_review?.approval_options ?? []) {
191
+ for (const label of approvalOptions) {
185
192
  if (!labels.has(label))
186
193
  context.addIssue({
187
194
  code: "custom",
@@ -197,6 +204,173 @@ export const askSchema = z.object({
197
204
 
198
205
  export type AskToolInput = z.infer<typeof askSchema>;
199
206
 
207
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
208
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
209
+ const prototype = Object.getPrototypeOf(value);
210
+ return prototype === Object.prototype || prototype === null;
211
+ }
212
+
213
+ function isOnlyPlainData(value: unknown): boolean {
214
+ if (Array.isArray(value)) return value.every(isOnlyPlainData);
215
+ if (typeof value !== "object" || value === null) return true;
216
+ return isPlainRecord(value) && Object.values(value).every(isOnlyPlainData);
217
+ }
218
+
219
+ function hasExactOwnKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
220
+ const keys = Reflect.ownKeys(value);
221
+ return keys.length === allowed.length && keys.every(key => typeof key === "string" && allowed.includes(key));
222
+ }
223
+
224
+ function hasOnlyAllowedOwnKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
225
+ return Reflect.ownKeys(value).every(key => typeof key === "string" && allowed.includes(key));
226
+ }
227
+
228
+ function hasUniqueDisplayedLabels(labels: readonly string[], optionLabels: ReadonlySet<string>): boolean {
229
+ return new Set(labels).size === labels.length && labels.every(label => optionLabels.has(label));
230
+ }
231
+
232
+ /** Parse only to recognize a retired recovery shape; parsed values are never eligible for recovery. */
233
+ function parseEncodedContainer(value: unknown): unknown {
234
+ if (typeof value !== "string") return value;
235
+ try {
236
+ return JSON.parse(value);
237
+ } catch {
238
+ return value;
239
+ }
240
+ }
241
+
242
+ /** Whether malformed input is close enough to the retired pair shape to require a terminal rejection. */
243
+ function isRoundZeroRecoveryCandidate(value: unknown): boolean {
244
+ const root = parseEncodedContainer(value);
245
+ if (typeof root !== "object" || root === null || !Object.hasOwn(root, "questions")) return false;
246
+ const questionsValue = parseEncodedContainer((root as Record<string, unknown>).questions);
247
+ if (!Array.isArray(questionsValue)) return questionsValue === null;
248
+ return questionsValue.some(rawQuestion => {
249
+ const question = parseEncodedContainer(rawQuestion);
250
+ if (typeof question !== "object" || question === null || !Object.hasOwn(question, "deepInterview")) return false;
251
+ const deepInterview = parseEncodedContainer((question as Record<string, unknown>).deepInterview);
252
+ if (typeof deepInterview !== "object" || deepInterview === null) return deepInterview === null;
253
+ const metadata = deepInterview as Record<string, unknown>;
254
+ return Object.hasOwn(metadata, "intent_contract") || Object.hasOwn(metadata, "intent_review");
255
+ });
256
+ }
257
+
258
+ /** Remove only strict-provider null placeholders for fields optional in the canonical Ask contract. */
259
+ function normalizeRoundZeroOptionalNulls(arguments_: Record<string, unknown>): Record<string, unknown> {
260
+ if (!isPlainRecord(arguments_) || !Array.isArray(arguments_.questions) || arguments_.questions.length !== 1)
261
+ return arguments_;
262
+ const question = arguments_.questions[0];
263
+ if (!isPlainRecord(question) || !isPlainRecord(question.deepInterview)) return arguments_;
264
+ const normalizedQuestion = { ...question };
265
+ let changed = false;
266
+ for (const key of ["multi", "recommended", "workflowGate"] as const) {
267
+ if (Object.hasOwn(normalizedQuestion, key) && normalizedQuestion[key] === null) {
268
+ delete normalizedQuestion[key];
269
+ changed = true;
270
+ }
271
+ }
272
+ const normalizedDeepInterview = { ...question.deepInterview };
273
+ if (Object.hasOwn(normalizedDeepInterview, "round_id") && normalizedDeepInterview.round_id === null) {
274
+ delete normalizedDeepInterview.round_id;
275
+ normalizedQuestion.deepInterview = normalizedDeepInterview;
276
+ changed = true;
277
+ }
278
+ return changed ? { ...arguments_, questions: [normalizedQuestion] } : arguments_;
279
+ }
280
+ function recoverRoundZeroIntentContract(arguments_: Record<string, unknown>): RawArgumentValidationResult {
281
+ if (!isRoundZeroRecoveryCandidate(arguments_)) return { outcome: "passthrough" };
282
+ const normalizedArguments = normalizeRoundZeroOptionalNulls(arguments_);
283
+ if (!isOnlyPlainData(normalizedArguments) || !isPlainRecord(normalizedArguments)) return { outcome: "reject" };
284
+ if (
285
+ !hasExactOwnKeys(normalizedArguments, ["questions"]) ||
286
+ !Array.isArray(normalizedArguments.questions) ||
287
+ normalizedArguments.questions.length !== 1
288
+ )
289
+ return { outcome: "reject" };
290
+
291
+ const question = normalizedArguments.questions[0];
292
+ if (!isPlainRecord(question)) return { outcome: "reject" };
293
+ const questionKeys = ["id", "question", "options", "multi", "recommended", "deepInterview", "workflowGate"];
294
+ if (!hasOnlyAllowedOwnKeys(question, questionKeys)) return { outcome: "reject" };
295
+ if (
296
+ typeof question.id !== "string" ||
297
+ typeof question.question !== "string" ||
298
+ !Array.isArray(question.options) ||
299
+ !Object.hasOwn(question, "deepInterview") ||
300
+ !isPlainRecord(question.deepInterview) ||
301
+ (Object.hasOwn(question, "multi") && question.multi !== false) ||
302
+ (Object.hasOwn(question, "recommended") && typeof question.recommended !== "number")
303
+ )
304
+ return { outcome: "reject" };
305
+ const deepInterview = question.deepInterview;
306
+ const hasIntentContract = Object.hasOwn(deepInterview, "intent_contract");
307
+ const hasIntentReview = Object.hasOwn(deepInterview, "intent_review");
308
+ if (hasIntentContract !== hasIntentReview && askSchema.safeParse(normalizedArguments).success)
309
+ return { outcome: "passthrough" };
310
+
311
+ if (
312
+ Object.hasOwn(question, "workflowGate") &&
313
+ (!isPlainRecord(question.workflowGate) ||
314
+ !hasExactOwnKeys(question.workflowGate, ["stage", "kind"]) ||
315
+ question.workflowGate.stage !== "deep-interview" ||
316
+ question.workflowGate.kind !== "question")
317
+ )
318
+ return { outcome: "reject" };
319
+
320
+ if (
321
+ !question.options.every(
322
+ option => isPlainRecord(option) && hasExactOwnKeys(option, ["label"]) && typeof option.label === "string",
323
+ )
324
+ )
325
+ return { outcome: "reject" };
326
+ const optionLabels = question.options.map(option => (option as { label: string }).label);
327
+ if (new Set(optionLabels).size !== optionLabels.length) return { outcome: "reject" };
328
+
329
+ const deepInterviewKeys = [
330
+ "round_id",
331
+ "round",
332
+ "component",
333
+ "dimension",
334
+ "ambiguity",
335
+ "intent_contract",
336
+ "intent_review",
337
+ ];
338
+ if (
339
+ !hasOnlyAllowedOwnKeys(deepInterview, deepInterviewKeys) ||
340
+ !Object.hasOwn(deepInterview, "intent_contract") ||
341
+ !Object.hasOwn(deepInterview, "intent_review") ||
342
+ deepInterview.round !== 0 ||
343
+ typeof deepInterview.component !== "string" ||
344
+ deepInterview.component !== "review-topology" ||
345
+ typeof deepInterview.dimension !== "string" ||
346
+ deepInterview.dimension !== "topology" ||
347
+ typeof deepInterview.ambiguity !== "number" ||
348
+ (Object.hasOwn(deepInterview, "round_id") && typeof deepInterview.round_id !== "string")
349
+ )
350
+ return { outcome: "reject" };
351
+
352
+ const contract = DeepInterviewIntentContract.safeParse(deepInterview.intent_contract);
353
+ const review = DeepInterviewIntentReview.safeParse(deepInterview.intent_review);
354
+ if (!contract.success || !review.success) return { outcome: "reject" };
355
+ const displayedLabels = new Set(optionLabels);
356
+ if (
357
+ !hasUniqueDisplayedLabels(contract.data.confirmation_options, displayedLabels) ||
358
+ !hasUniqueDisplayedLabels(review.data.approval_options, displayedLabels)
359
+ )
360
+ return { outcome: "reject" };
361
+
362
+ const { intent_review: _intentReview, ...recoveredDeepInterview } = deepInterview;
363
+ const recovered = {
364
+ questions: [
365
+ {
366
+ ...question,
367
+ deepInterview: { ...recoveredDeepInterview, intent_contract: contract.data },
368
+ },
369
+ ],
370
+ };
371
+ return askSchema.safeParse(recovered).success ? { outcome: "accept", arguments: recovered } : { outcome: "reject" };
372
+ }
373
+
200
374
  /** Result for a single question */
201
375
  export interface QuestionResult {
202
376
  id: string;
@@ -803,6 +977,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
803
977
  readonly summary = "Ask the user a clarifying question";
804
978
  readonly description: string;
805
979
  readonly parameters = askSchema;
980
+ readonly rawArgumentValidation = recoverRoundZeroIntentContract;
806
981
  readonly strict = true;
807
982
  readonly loadMode = "discoverable";
808
983
 
@@ -850,14 +1025,14 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
850
1025
  ambiguity: meta.ambiguity,
851
1026
  selectedOptions,
852
1027
  customInput,
853
- intent_contract: meta.intent_contract,
854
- intent_review: meta.intent_review,
1028
+ intent_contract: intentContract(meta),
1029
+ intent_review: intentReview(meta),
855
1030
  },
856
1031
  { sessionId },
857
1032
  ).then(async () => {
858
1033
  await syncDeepInterviewRecorderHud(cwd, statePath, sessionId);
859
1034
  }),
860
- meta.intent_contract !== undefined || meta.intent_review !== undefined,
1035
+ intentContract(meta) !== undefined || intentReview(meta) !== undefined,
861
1036
  );
862
1037
  }
863
1038
 
@@ -1190,7 +1365,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
1190
1365
  navigation: options?.navigation,
1191
1366
  scrollTitleRows: isDeepInterviewQuestion ? DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS : undefined,
1192
1367
  otherOptionLabel,
1193
- autoSelectOnTimeout: !q.deepInterview?.intent_contract && !q.deepInterview?.intent_review,
1368
+ autoSelectOnTimeout: !intentContract(q.deepInterview) && !intentReview(q.deepInterview),
1194
1369
  clarificationOptionLabel,
1195
1370
  onRemoteState: state => {
1196
1371
  activeRemoteRequest = {
@@ -1277,7 +1452,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
1277
1452
  }
1278
1453
  if (
1279
1454
  clarificationQuestion === undefined &&
1280
- !(timedOut && (q.deepInterview?.intent_contract || q.deepInterview?.intent_review))
1455
+ !(timedOut && (intentContract(q.deepInterview) || intentReview(q.deepInterview)))
1281
1456
  ) {
1282
1457
  await this.#recordDeepInterviewRound(q, selectedOptions, customInput);
1283
1458
  }
@@ -1358,7 +1533,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
1358
1533
 
1359
1534
  if (
1360
1535
  clarificationQuestion === undefined &&
1361
- !(timedOut && (q.deepInterview?.intent_contract || q.deepInterview?.intent_review))
1536
+ !(timedOut && (intentContract(q.deepInterview) || intentReview(q.deepInterview)))
1362
1537
  ) {
1363
1538
  await this.#recordDeepInterviewRound(q, selectedOptions, customInput);
1364
1539
  }
@@ -1,3 +1,11 @@
1
+ import {
2
+ classifyStateArgv,
3
+ STATE_ACTION_NAMES,
4
+ type StateAction,
5
+ type StateArgvClassification,
6
+ } from "../gjc-runtime/state-argv";
7
+ import { CANONICAL_GJC_WORKFLOW_SKILLS } from "../skill-state/canonical-skills";
8
+
1
9
  export interface BashAllowedPrefixesCheck {
2
10
  allowed: boolean;
3
11
  reason?: string;
@@ -11,9 +19,8 @@ export interface BashRestrictionOptions {
11
19
 
12
20
  const SHELL_CONTROL_CHARS = new Set([";", "|", "&", "<", ">", "(", ")"]);
13
21
  const UNSAFE_UNQUOTED_EXPANSION_CHARS = new Set(["$", "*", "?", "[", "]", "{", "}", "~"]);
14
- const STATE_FLAGS_WITH_VALUES = new Set(["--input", "--mode", "--session-id", "--thread-id", "--turn-id", "--to"]);
15
- const STATE_ACTIONS = new Set(["read", "write", "clear", "contract", "handoff"]);
16
22
  const ALLOWED_STATE_ACTIONS = new Set(["read", "write", "contract"]);
23
+ const CANONICAL_STATE_TARGETS = new Set<string>(CANONICAL_GJC_WORKFLOW_SKILLS);
17
24
  const READ_ONLY_COMMANDS = new Set(["grep", "rg", "tree", "ls", "pwd", "wc", "du", "file", "stat"]);
18
25
 
19
26
  function parseShellWords(command: string): { words: string[]; reason?: string } {
@@ -21,6 +28,7 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
21
28
  let current = "";
22
29
  let quote: "single" | "double" | null = null;
23
30
 
31
+ let wordStarted = false;
24
32
  for (let index = 0; index < command.length; index += 1) {
25
33
  const char = command[index]!;
26
34
  const next = command[index + 1];
@@ -54,10 +62,12 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
54
62
 
55
63
  if (char === "'") {
56
64
  quote = "single";
65
+ wordStarted = true;
57
66
  continue;
58
67
  }
59
68
  if (char === '"') {
60
69
  quote = "double";
70
+ wordStarted = true;
61
71
  continue;
62
72
  }
63
73
  if (char === "`" || (char === "$" && next === "(")) {
@@ -73,9 +83,10 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
73
83
  return { words, reason: `shell expansion character '${char}' is not allowed in restricted bash commands` };
74
84
  }
75
85
  if (/\s/u.test(char)) {
76
- if (current.length > 0) {
86
+ if (wordStarted) {
77
87
  words.push(current);
78
88
  current = "";
89
+ wordStarted = false;
79
90
  }
80
91
  continue;
81
92
  }
@@ -83,12 +94,13 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
83
94
  return { words, reason: "backslash escapes are not allowed in restricted bash commands" };
84
95
  }
85
96
  current += char;
97
+ wordStarted = true;
86
98
  }
87
99
 
88
100
  if (quote !== null) {
89
101
  return { words, reason: "unterminated quote in restricted bash command" };
90
102
  }
91
- if (current.length > 0) words.push(current);
103
+ if (wordStarted) words.push(current);
92
104
  return { words };
93
105
  }
94
106
 
@@ -101,28 +113,62 @@ function wordsStartWith(words: readonly string[], prefix: readonly string[]): bo
101
113
  return prefix.every((word, index) => words[index] === word);
102
114
  }
103
115
 
104
- function parseStateAction(words: readonly string[]): string | undefined {
105
- const args = words.slice(2);
106
- const positional: string[] = [];
107
- let skipNext = false;
108
- for (const arg of args) {
109
- if (skipNext) {
110
- skipNext = false;
111
- continue;
112
- }
113
- if (STATE_FLAGS_WITH_VALUES.has(arg)) {
114
- skipNext = true;
115
- continue;
116
- }
117
- if (!arg.startsWith("-")) positional.push(arg);
116
+ function malformedStateShape(): StateActionClassification {
117
+ return { reason: "restricted role-agent bash only allows documented `gjc state` action shapes" };
118
+ }
119
+
120
+ interface ParsedStateAction {
121
+ action: string;
122
+ target: string;
123
+ }
124
+
125
+ interface StateActionClassification {
126
+ parsed?: ParsedStateAction;
127
+ reason?: string;
128
+ }
129
+
130
+ function hasDocumentedStatePositionals(classification: StateArgvClassification): boolean {
131
+ const [first, second, third] = classification.positionals;
132
+ if (third) return false;
133
+ if (!second) return true;
134
+ return STATE_ACTION_NAMES.has(first as StateAction) || STATE_ACTION_NAMES.has(second as StateAction);
135
+ }
136
+
137
+ function classifyStateAction(words: readonly string[]): StateActionClassification {
138
+ const classification = classifyStateArgv(words.slice(2));
139
+ if (
140
+ classification.unknownFlags.length > 0 ||
141
+ classification.flags.some(flag => flag.malformed) ||
142
+ !hasDocumentedStatePositionals(classification)
143
+ ) {
144
+ return malformedStateShape();
145
+ }
146
+
147
+ const modeFlags = classification.flags.filter(flag => flag.name === "--mode");
148
+ const inputFlags = classification.flags.filter(flag => flag.name === "--input");
149
+ if (modeFlags.length > 1 || inputFlags.length > 1) {
150
+ return { reason: "restricted role-agent bash rejects repeated or conflicting `gjc state` target selectors" };
151
+ }
152
+ if (classification.inputs.some(input => input.kind === "file")) {
153
+ return { reason: "restricted role-agent bash does not allow file-backed `gjc state --input` values" };
118
154
  }
155
+ if (classification.inputs.some(input => input.kind === "invalid")) return malformedStateShape();
119
156
 
120
- const [first, second, third] = positional;
121
- if (!first) return "read";
122
- if (STATE_ACTIONS.has(first)) return second ? undefined : first;
123
- if (!second) return "read";
124
- if (!STATE_ACTIONS.has(second)) return undefined;
125
- return third ? undefined : second;
157
+ const runtimeTarget = classification.runtimeSelectorCandidates.find(candidate => candidate.value)?.value;
158
+ const suppliedTargets = classification.selectorCandidates
159
+ .map(candidate => candidate.value)
160
+ .filter((target): target is string => target !== undefined);
161
+ const distinctTargets = new Set(suppliedTargets);
162
+ if (distinctTargets.size > 1 || (runtimeTarget && suppliedTargets.some(target => target !== runtimeTarget))) {
163
+ return {
164
+ reason:
165
+ "restricted role-agent bash rejects conflicting `gjc state` selectors that disagree with runtime precedence",
166
+ };
167
+ }
168
+ if (!runtimeTarget || !CANONICAL_STATE_TARGETS.has(runtimeTarget)) {
169
+ return { reason: "restricted role-agent bash requires a canonical workflow skill for `gjc state` commands" };
170
+ }
171
+ return { parsed: { action: classification.effectiveAction, target: runtimeTarget } };
126
172
  }
127
173
 
128
174
  function optionWords(words: readonly string[]): string[] {
@@ -189,15 +235,19 @@ function validateMatchedGjcCommand(words: readonly string[]): BashAllowedPrefixe
189
235
  }
190
236
 
191
237
  if (words[1] === "state") {
192
- const action = parseStateAction(words);
193
- if (!action) {
238
+ const classification = classifyStateAction(words);
239
+ if (!classification.parsed) {
194
240
  return {
195
241
  allowed: false,
196
- reason: "restricted role-agent bash only allows documented `gjc state` action shapes",
242
+ reason:
243
+ classification.reason ?? "restricted role-agent bash only allows documented `gjc state` action shapes",
197
244
  };
198
245
  }
199
- if (!ALLOWED_STATE_ACTIONS.has(action)) {
200
- return { allowed: false, reason: `restricted role-agent bash does not allow \`gjc state ${action}\`` };
246
+ if (!ALLOWED_STATE_ACTIONS.has(classification.parsed.action)) {
247
+ return {
248
+ allowed: false,
249
+ reason: `restricted role-agent bash does not allow \`gjc state ${classification.parsed.action}\``,
250
+ };
201
251
  }
202
252
  return { allowed: true };
203
253
  }
@@ -1,7 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { $which, getPuppeteerDir, logger } from "@gajae-code/utils";
4
+ import { getPuppeteerDir } from "@gajae-code/utils/dirs";
5
+ import * as logger from "@gajae-code/utils/logger";
6
+ import { $which } from "@gajae-code/utils/which";
5
7
  import * as browsers from "@puppeteer/browsers";
6
8
  import type { Browser, CDPSession, Page, default as Puppeteer, Target } from "puppeteer-core";
7
9
  import { PUPPETEER_REVISIONS } from "puppeteer-core/internal/revisions.js";
@@ -1,6 +1,6 @@
1
1
  import { Readability } from "@mozilla/readability";
2
2
  import { parseHtmlLazy } from "../../utils/linkedom";
3
- import { htmlToBasicMarkdown } from "../../web/scrapers/types";
3
+ import { htmlToBasicMarkdown } from "../../web/scrapers/html-to-markdown";
4
4
 
5
5
  export type ReadableFormat = "text" | "markdown";
6
6
 
@@ -0,0 +1,34 @@
1
+ import * as os from "node:os";
2
+
3
+ import { formatDimensionNote, type ResizedImage } from "../../utils/image-resize";
4
+
5
+ function shortenPath(filePath: string): string {
6
+ const home = os.homedir();
7
+ return home && filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
8
+ }
9
+
10
+ export function formatScreenshot(opts: {
11
+ saveFullRes: boolean;
12
+ savedMimeType: string;
13
+ savedByteLength: number;
14
+ dest: string;
15
+ resized: ResizedImage;
16
+ }): string[] {
17
+ const lines = ["Screenshot captured"];
18
+ if (opts.saveFullRes) {
19
+ lines.push(
20
+ `Saved: ${opts.savedMimeType} (${(opts.savedByteLength / 1024).toFixed(2)} KB) to ${shortenPath(opts.dest)}`,
21
+ );
22
+ lines.push(
23
+ `Model: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB, ${opts.resized.width}x${opts.resized.height})`,
24
+ );
25
+ } else {
26
+ lines.push(`Format: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB)`);
27
+ lines.push(`Dimensions: ${opts.resized.width}x${opts.resized.height}`);
28
+ }
29
+ const dimensionNote = formatDimensionNote(opts.resized);
30
+ if (dimensionNote) {
31
+ lines.push(dimensionNote);
32
+ }
33
+ return lines;
34
+ }
@@ -63,6 +63,7 @@ export type WorkerInitPayload =
63
63
  export type ToolReply = { ok: true; value: unknown } | { ok: false; error: RunErrorPayload };
64
64
 
65
65
  export type WorkerInbound =
66
+ | { type: "bootstrap"; version: 1; mode: "native-free" }
66
67
  | { type: "init"; payload: WorkerInitPayload }
67
68
  | { type: "run"; id: string; name: string; code: string; timeoutMs: number; session: SessionSnapshot }
68
69
  | { type: "abort"; id: string }
@@ -91,6 +92,8 @@ export interface RunErrorPayload {
91
92
  }
92
93
 
93
94
  export type WorkerOutbound =
95
+ | { type: "bootstrap-ready"; version: 1; mode: "native-free" }
96
+ | { type: "bootstrap-failed"; error: string }
94
97
  | { type: "ready"; info: ReadyInfo }
95
98
  | { type: "init-failed"; error: RunErrorPayload }
96
99
  | { type: "result"; id: string; ok: true; payload: RunResultOk }