@sayknow-cli/coding-agent 0.5.1 → 0.5.6

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 (122) hide show
  1. package/CHANGELOG.md +185 -14
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/commands/session.d.ts +7 -0
  4. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-registry.d.ts +1 -1
  7. package/dist/types/config/models-config-schema.d.ts +4 -0
  8. package/dist/types/config/settings-schema.d.ts +9 -0
  9. package/dist/types/config/telegram-autostart.d.ts +9 -1
  10. package/dist/types/dap/client.d.ts +1 -0
  11. package/dist/types/lsp/client.d.ts +5 -0
  12. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  13. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  14. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  15. package/dist/types/modes/components/welcome.d.ts +1 -0
  16. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +13 -1
  18. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  19. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  20. package/dist/types/modes/types.d.ts +7 -1
  21. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  22. package/dist/types/session/agent-session.d.ts +9 -0
  23. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  24. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  25. package/dist/types/session/session-manager.d.ts +9 -0
  26. package/dist/types/session/session-storage.d.ts +17 -0
  27. package/dist/types/session-import/claude.d.ts +26 -0
  28. package/dist/types/session-import/codex.d.ts +22 -0
  29. package/dist/types/session-import/command.d.ts +20 -0
  30. package/dist/types/session-import/detect.d.ts +22 -0
  31. package/dist/types/session-import/index.d.ts +7 -0
  32. package/dist/types/session-import/redact.d.ts +23 -0
  33. package/dist/types/session-import/service.d.ts +28 -0
  34. package/dist/types/session-import/types.d.ts +125 -0
  35. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  36. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  37. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  38. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  39. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  40. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  41. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  42. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  43. package/dist/types/slash-commands/types.d.ts +5 -0
  44. package/dist/types/tools/ask.d.ts +169 -4
  45. package/package.json +16 -7
  46. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  47. package/src/cli/config-cli.ts +23 -8
  48. package/src/cli/update-cli.ts +265 -21
  49. package/src/commands/session.ts +88 -2
  50. package/src/config/atomic-yaml-patch.ts +167 -30
  51. package/src/config/keybindings.ts +10 -0
  52. package/src/config/model-profiles.ts +69 -1
  53. package/src/config/model-registry.ts +72 -38
  54. package/src/config/models-config-schema.ts +1 -1
  55. package/src/config/settings-schema.ts +9 -0
  56. package/src/config/settings.ts +13 -2
  57. package/src/config/telegram-autostart.ts +11 -4
  58. package/src/dap/client.ts +78 -30
  59. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  60. package/src/internal-urls/docs-index.generated.ts +5 -4
  61. package/src/lsp/client.ts +17 -29
  62. package/src/main.ts +1 -1
  63. package/src/modes/action-registry.ts +2 -0
  64. package/src/modes/components/pet-capability.ts +22 -13
  65. package/src/modes/components/pet-selector.ts +1 -1
  66. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  67. package/src/modes/components/welcome.ts +5 -0
  68. package/src/modes/controllers/event-controller.ts +1 -1
  69. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  70. package/src/modes/controllers/input-controller.ts +71 -6
  71. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  72. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  73. package/src/modes/controllers/selector-controller.ts +47 -19
  74. package/src/modes/interactive-mode.ts +54 -4
  75. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  76. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  77. package/src/modes/types.ts +5 -1
  78. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  79. package/src/prompts/system/eager-todo.md +2 -0
  80. package/src/prompts/system/plan-mode-approved.md +1 -1
  81. package/src/prompts/system/system-prompt.md +4 -2
  82. package/src/runtime-mcp/transports/stdio.ts +96 -33
  83. package/src/sdk/broker/lifecycle.ts +6 -5
  84. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  85. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  86. package/src/sdk/session.ts +25 -18
  87. package/src/session/agent-session.ts +120 -25
  88. package/src/session/internal/managed-session-scope.ts +41 -6
  89. package/src/session/internal/managed-session-storage.ts +14 -0
  90. package/src/session/session-manager.ts +113 -11
  91. package/src/session/session-storage.ts +70 -0
  92. package/src/session-import/claude.ts +382 -0
  93. package/src/session-import/codex.ts +458 -0
  94. package/src/session-import/command.ts +61 -0
  95. package/src/session-import/detect.ts +133 -0
  96. package/src/session-import/index.ts +27 -0
  97. package/src/session-import/redact.ts +125 -0
  98. package/src/session-import/service.ts +749 -0
  99. package/src/session-import/types.ts +163 -0
  100. package/src/skc-runtime/boot-generation.ts +172 -0
  101. package/src/skc-runtime/launch-tmux.ts +219 -41
  102. package/src/skc-runtime/launch-worktree.ts +277 -4
  103. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  104. package/src/skc-runtime/session-restore.ts +296 -0
  105. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  106. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  107. package/src/skc-runtime/tmux-sessions.ts +284 -108
  108. package/src/slash-commands/acp-builtins.ts +5 -1
  109. package/src/slash-commands/builtin-registry.ts +109 -5
  110. package/src/slash-commands/types.ts +5 -0
  111. package/src/tools/ask.ts +188 -10
  112. package/src/tools/eval.ts +2 -2
  113. package/src/export/html/template.css +0 -1060
  114. package/src/export/html/template.html +0 -47
  115. package/src/export/html/template.js +0 -2348
  116. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  117. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  118. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  119. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  120. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  121. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  122. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
package/src/tools/ask.ts CHANGED
@@ -39,6 +39,7 @@ import { getMarkdownTheme, type Theme, theme } from "../modes/theme/theme";
39
39
  import askDescription from "../prompts/tools/ask.md" with { type: "text" };
40
40
  import { appendOrMergeDeepInterviewRound, syncDeepInterviewRecorderHud } from "../skc-runtime/deep-interview-recorder";
41
41
  import { deepInterviewStatePath } from "../skc-runtime/deep-interview-runtime";
42
+ import { DEEP_INTERVIEW_INTENT_CATEGORIES, deepInterviewCharacterCount } from "../skc-runtime/deep-interview-state";
42
43
  import { renderStatusLine } from "../tui";
43
44
  import type { ToolSession } from ".";
44
45
  import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
@@ -53,13 +54,114 @@ const OptionItem = z.object({
53
54
  label: z.string().describe("display label"),
54
55
  });
55
56
 
56
- /** Optional structured deep-interview round metadata; when present the round is recorded automatically. */
57
- const DeepInterviewMeta = z.object({
58
- round_id: z.string().describe("stable optional round identity").optional(),
57
+ /**
58
+ * Bounded by code points, not UTF-16 units: an emoji-padded field must hit the same
59
+ * ceiling the gate adapter and interview state enforce, or oversized metadata survives
60
+ * the tool boundary and only fails deeper in the recorder.
61
+ */
62
+ const boundedText = (max: number) => z.string().refine(value => deepInterviewCharacterCount(value) <= max);
63
+ const ADAPTER_TEXT_MAX = 256;
64
+ const CORE_TEXT_MAX = 128;
65
+ const LONG_TEXT_MAX = 2048;
66
+
67
+ /** Inert adapter context: never fetched, never allowed to alter the first question. */
68
+ const AskGateReferenceItem = z
69
+ .object({
70
+ reference_id: boundedText(ADAPTER_TEXT_MAX),
71
+ label: boundedText(ADAPTER_TEXT_MAX),
72
+ origin: boundedText(ADAPTER_TEXT_MAX),
73
+ url: boundedText(LONG_TEXT_MAX).optional(),
74
+ excerpt: boundedText(LONG_TEXT_MAX).optional(),
75
+ })
76
+ .strict();
77
+
78
+ /** One locked-intent line. `id` MUST carry its own category as a prefix. */
79
+ const IntentItem = z
80
+ .object({
81
+ id: z.string().min(1),
82
+ category: z.enum(DEEP_INTERVIEW_INTENT_CATEGORIES),
83
+ statement: boundedText(1_000).min(1),
84
+ })
85
+ .strict()
86
+ .refine(item => item.id.startsWith(`${item.category}:`));
87
+
88
+ const IntentSubstitution = z
89
+ .object({
90
+ removed_id: z.string().min(1),
91
+ replacement_ids: z.array(z.string().min(1)),
92
+ rationale: boundedText(500),
93
+ })
94
+ .strict();
95
+
96
+ const IntentContract = z
97
+ .object({ items: z.array(IntentItem).min(1).max(64), confirmation_options: z.array(z.string().min(1)).min(1) })
98
+ .strict()
99
+ .describe("Round 0 locked-intent manifest proposal");
100
+
101
+ const IntentReview = z
102
+ .object({
103
+ observed_items: z.array(IntentItem).min(1).max(64),
104
+ supporting_substitutions: z.array(IntentSubstitution).max(64),
105
+ approval_options: z.array(z.string().min(1)).min(1),
106
+ })
107
+ .strict()
108
+ .describe("post-Round-0 locked-intent reduction review");
109
+
110
+ const deepInterviewMetaBase = {
111
+ round_id: boundedText(CORE_TEXT_MAX).describe("stable optional round identity").optional(),
59
112
  round: z.number().int().nonnegative().describe("round number"),
60
- component: z.string().min(1).describe("targeted topology component"),
61
- dimension: z.string().min(1).describe("targeted clarity dimension"),
113
+ component: boundedText(CORE_TEXT_MAX).min(1).describe("targeted topology component"),
114
+ dimension: boundedText(CORE_TEXT_MAX).min(1).describe("targeted clarity dimension"),
62
115
  ambiguity: z.number().min(0).max(1).describe("ambiguity at ask time (0..1)"),
116
+ confused_terms: z
117
+ .array(boundedText(ADAPTER_TEXT_MAX))
118
+ .max(32)
119
+ .describe("inert adapter vocabulary; never alters the question")
120
+ .optional(),
121
+ references: z
122
+ .array(AskGateReferenceItem)
123
+ .max(32)
124
+ .describe("inert adapter references; never auto-fetched")
125
+ .optional(),
126
+ };
127
+
128
+ /**
129
+ * Optional structured deep-interview round metadata; when present the round is
130
+ * recorded automatically. The three branches are mutually exclusive **on the
131
+ * wire**, not just at parse time: the manifest is locked exactly once, by the
132
+ * Round 0 topology confirmation (accepting a contract on any later round would
133
+ * let a mid-interview answer replace the locked intent), and a reduction review
134
+ * only exists after Round 0 has locked a manifest. Providers that only see the
135
+ * JSON schema must be unable to combine the two.
136
+ */
137
+ const DeepInterviewMeta = z.union([
138
+ // Branch order is load-bearing: the intent-carrying branches must win when
139
+ // their key is present, and every branch is strict so a contract or review
140
+ // attached to the wrong round fails closed instead of being silently
141
+ // stripped by a laxer branch.
142
+ z
143
+ .object({
144
+ ...deepInterviewMetaBase,
145
+ round: z.literal(0).describe("the manifest is locked exactly once, at Round 0"),
146
+ component: z.literal("review-topology"),
147
+ intent_contract: IntentContract,
148
+ })
149
+ .strict()
150
+ .describe("Round 0 topology confirmation carrying the locked-intent manifest proposal"),
151
+ z
152
+ .object({
153
+ ...deepInterviewMetaBase,
154
+ round: z.number().int().min(1).describe("reviews only exist after Round 0 locked a manifest"),
155
+ intent_review: IntentReview,
156
+ })
157
+ .strict()
158
+ .describe("post-Round-0 locked-intent reduction review"),
159
+ z.object(deepInterviewMetaBase).strict().describe("ordinary interview round metadata"),
160
+ ]);
161
+
162
+ const WorkflowGateMeta = z.object({
163
+ stage: z.enum(["deep-interview", "ralplan", "ultragoal"]).describe("workflow gate stage"),
164
+ kind: z.enum(["question", "approval", "execution"]).describe("workflow gate kind"),
63
165
  });
64
166
 
65
167
  const QuestionItem = z.object({
@@ -68,6 +170,7 @@ const QuestionItem = z.object({
68
170
  options: z.array(OptionItem).describe("available options"),
69
171
  multi: z.boolean().describe("allow multiple selections").optional(),
70
172
  recommended: z.number().describe("recommended option index").optional(),
173
+ workflowGate: WorkflowGateMeta.describe("optional workflow gate stage/kind override").optional(),
71
174
  deepInterview: DeepInterviewMeta.describe("optional deep-interview round metadata").optional(),
72
175
  });
73
176
 
@@ -93,6 +196,8 @@ export interface AskToolDetails {
93
196
  multi?: boolean;
94
197
  selectedOptions?: string[];
95
198
  customInput?: string;
199
+ /** Set when the user answered with a clarification question instead of a choice. */
200
+ clarificationQuestion?: string;
96
201
  /** Multi-part question mode */
97
202
  results?: QuestionResult[];
98
203
  }
@@ -490,6 +595,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
490
595
  readonly description: string;
491
596
  readonly parameters = askSchema;
492
597
  readonly strict = true;
598
+ /**
599
+ * These fields are rendered to the user and carry no executable or durable
600
+ * meaning. IDs and workflow/deep-interview metadata remain fail-closed.
601
+ */
602
+ readonly displaySafeEscapedArgFields = ["questions.question", "questions.options.label"] as const;
493
603
  readonly loadMode = "discoverable";
494
604
 
495
605
  constructor(private readonly session: ToolSession) {
@@ -519,6 +629,9 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
519
629
  ): Promise<void> {
520
630
  const meta = q.deepInterview;
521
631
  if (!meta) return;
632
+ // A stage/kind override addresses a non-deep-interview gate; recording it as
633
+ // an interview round would corrupt the interview state.
634
+ if (q.workflowGate && (q.workflowGate.stage !== "deep-interview" || q.workflowGate.kind !== "question")) return;
522
635
  const cwd = this.session.cwd;
523
636
  const sessionId = this.session.getSessionId?.() ?? undefined;
524
637
  const statePath = deepInterviewStatePath(cwd, sessionId);
@@ -536,6 +649,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
536
649
  ambiguity: meta.ambiguity,
537
650
  selectedOptions,
538
651
  customInput,
652
+ // The Round 0 manifest is locked from the confirmation answer itself, and any
653
+ // later reduction is reviewed against that lock. Dropping them here leaves the
654
+ // recorder nothing to bind, so the "locked intent" is never actually locked.
655
+ intent_contract: "intent_contract" in meta ? meta.intent_contract : undefined,
656
+ intent_review: "intent_review" in meta ? meta.intent_review : undefined,
539
657
  },
540
658
  { sessionId },
541
659
  ).then(async () => {
@@ -553,7 +671,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
553
671
  ): Promise<AgentToolResult<AskToolDetails>> {
554
672
  await assertUltragoalAskAllowed(this.session.cwd);
555
673
  const gateEmitter = this.session.getWorkflowGateEmitter?.();
556
- const canUseWorkflowGate = !context?.hasUI && gateEmitter?.supportsRemoteGateAnswers() === true;
674
+ // A negotiated unattended run answers through workflow gates even when a UI
675
+ // exists; otherwise gates are the headless answer path only.
676
+ const canUseWorkflowGate =
677
+ gateEmitter?.supportsRemoteGateAnswers() === true &&
678
+ (gateEmitter.isUnattended?.() === true || !context?.hasUI);
557
679
 
558
680
  // Headless fallback: unattended workflow gates are the non-TUI answer path.
559
681
  if (!canUseWorkflowGate && (!context?.hasUI || !context.ui)) {
@@ -652,7 +774,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
652
774
 
653
775
  const askQuestion = async (
654
776
  q: AskParams["questions"][number],
655
- options?: { previous?: QuestionResult; navigation?: NavigationControls },
777
+ options?: {
778
+ previous?: QuestionResult;
779
+ navigation?: NavigationControls;
780
+ navigationLabel?: "Next" | "Done";
781
+ },
656
782
  ) => {
657
783
  const rawOptionLabels = q.options.map(o => o.label);
658
784
  // Unattended (#316/#323/G011): route the question through the workflow-gate
@@ -663,7 +789,12 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
663
789
  question: q.question,
664
790
  options: q.options,
665
791
  multi: q.multi,
792
+ // Interactive multi-select allows finishing with nothing selected
793
+ // ("Next"); the gate path must keep the same empty-answer semantics.
794
+ allowEmpty: q.multi === true,
795
+ navigationLabel: options?.navigationLabel,
666
796
  recommended: q.recommended,
797
+ workflowGate: q.workflowGate,
667
798
  deepInterview: q.deepInterview,
668
799
  };
669
800
  const answer = await gateEmitter.emitGate(questionToGate(gateQuestion));
@@ -672,6 +803,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
672
803
  optionLabels: rawOptionLabels,
673
804
  selectedOptions: decoded.selectedOptions,
674
805
  customInput: decoded.customInput,
806
+ clarificationQuestion: decoded.clarificationQuestion,
675
807
  navigation: undefined as NavigationControls | undefined,
676
808
  cancelled: false,
677
809
  timedOut: false,
@@ -716,7 +848,15 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
716
848
  return displayIndex >= 0 ? (rawOptionLabels[displayIndex] ?? selected) : selected;
717
849
  })
718
850
  : displaySelectedOptions;
719
- return { optionLabels: rawOptionLabels, selectedOptions, customInput, navigation, cancelled, timedOut };
851
+ return {
852
+ optionLabels: rawOptionLabels,
853
+ selectedOptions,
854
+ customInput,
855
+ clarificationQuestion: undefined as string | undefined,
856
+ navigation,
857
+ cancelled,
858
+ timedOut,
859
+ };
720
860
  } catch (error) {
721
861
  if (error instanceof Error && error.name === "AbortError") {
722
862
  throw new ToolAbortError("Ask input was cancelled");
@@ -727,8 +867,12 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
727
867
 
728
868
  if (params.questions.length === 1) {
729
869
  const [q] = params.questions;
730
- const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q);
870
+ const { optionLabels, selectedOptions, customInput, clarificationQuestion, cancelled, timedOut } =
871
+ await askQuestion(q);
731
872
 
873
+ if (clarificationQuestion !== undefined) {
874
+ return this.#clarificationResult(q, optionLabels, clarificationQuestion);
875
+ }
732
876
  if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) {
733
877
  context?.abort();
734
878
  throw new ToolAbortError("Ask tool was cancelled by the user");
@@ -777,11 +921,21 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
777
921
  optionLabels,
778
922
  selectedOptions,
779
923
  customInput,
924
+ clarificationQuestion,
780
925
  navigation: navAction,
781
926
  cancelled,
782
927
  timedOut,
783
- } = await askQuestion(q, { previous, navigation });
928
+ } = await askQuestion(q, {
929
+ previous,
930
+ navigation,
931
+ navigationLabel: questionIndex + 1 < params.questions.length ? "Next" : "Done",
932
+ });
784
933
 
934
+ // A clarification is an answer about the ask itself: stop the sequence and
935
+ // surface the question instead of treating the empty selection as a cancel.
936
+ if (clarificationQuestion !== undefined) {
937
+ return this.#clarificationResult(q, optionLabels, clarificationQuestion);
938
+ }
785
939
  if (cancelled && !timedOut) {
786
940
  context?.abort();
787
941
  throw new ToolAbortError("Ask tool was cancelled by the user");
@@ -824,6 +978,30 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
824
978
 
825
979
  return { content: [{ type: "text" as const, text: responseText }], details };
826
980
  }
981
+
982
+ /** A clarification answer surfaces the user's question instead of a selection. */
983
+ #clarificationResult(
984
+ q: AskParams["questions"][number],
985
+ optionLabels: string[],
986
+ clarificationQuestion: string,
987
+ ): AgentToolResult<AskToolDetails> {
988
+ return {
989
+ content: [
990
+ {
991
+ type: "text" as const,
992
+ text: `User asked a clarification question about the choices: ${clarificationQuestion}`,
993
+ },
994
+ ],
995
+ details: {
996
+ question: q.question,
997
+ options: optionLabels,
998
+ multi: q.multi ?? false,
999
+ selectedOptions: [],
1000
+ customInput: undefined,
1001
+ clarificationQuestion,
1002
+ },
1003
+ };
1004
+ }
827
1005
  }
828
1006
 
829
1007
  // =============================================================================
package/src/tools/eval.ts CHANGED
@@ -183,8 +183,8 @@ function timeoutSecondsFromMs(timeoutMs: number): number {
183
183
  }
184
184
 
185
185
  async function resolveBackend(session: ToolSession, language: EvalLanguage): Promise<ResolvedBackend> {
186
- const allowPy = (session.settings.get("eval.py") as boolean | undefined) ?? true;
187
- const allowJs = (session.settings.get("eval.js") as boolean | undefined) ?? true;
186
+ // SKC_PY wins over the legacy PI_PY/PI_JS env flags, which win over settings.
187
+ const { python: allowPy, js: allowJs } = resolveEvalBackends(session);
188
188
 
189
189
  if (language === "python") {
190
190
  if (!allowPy) throw new ToolError("Python backend is disabled (eval.py = false).");