@sayknow-cli/coding-agent 0.5.1 → 0.5.2

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 (45) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/dist/types/commands/session.d.ts +7 -0
  3. package/dist/types/config/telegram-autostart.d.ts +9 -1
  4. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  5. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  6. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  7. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  8. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  9. package/dist/types/session/agent-session.d.ts +1 -0
  10. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  11. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  12. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  13. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  14. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  15. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  16. package/dist/types/tools/ask.d.ts +164 -4
  17. package/package.json +10 -7
  18. package/src/commands/session.ts +88 -2
  19. package/src/config/model-registry.ts +12 -0
  20. package/src/config/telegram-autostart.ts +11 -4
  21. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  22. package/src/internal-urls/docs-index.generated.ts +1 -1
  23. package/src/main.ts +1 -1
  24. package/src/modes/components/pet-capability.ts +22 -13
  25. package/src/modes/components/pet-selector.ts +1 -1
  26. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  27. package/src/modes/controllers/event-controller.ts +1 -1
  28. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  29. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  30. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  31. package/src/prompts/system/eager-todo.md +2 -0
  32. package/src/prompts/system/plan-mode-approved.md +1 -1
  33. package/src/prompts/system/system-prompt.md +4 -2
  34. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  35. package/src/session/agent-session.ts +31 -11
  36. package/src/skc-runtime/boot-generation.ts +172 -0
  37. package/src/skc-runtime/launch-tmux.ts +219 -41
  38. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  39. package/src/skc-runtime/session-restore.ts +296 -0
  40. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  41. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  42. package/src/skc-runtime/tmux-sessions.ts +284 -108
  43. package/src/slash-commands/builtin-registry.ts +9 -4
  44. package/src/tools/ask.ts +183 -10
  45. package/src/tools/eval.ts +2 -2
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
  }
@@ -519,6 +624,9 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
519
624
  ): Promise<void> {
520
625
  const meta = q.deepInterview;
521
626
  if (!meta) return;
627
+ // A stage/kind override addresses a non-deep-interview gate; recording it as
628
+ // an interview round would corrupt the interview state.
629
+ if (q.workflowGate && (q.workflowGate.stage !== "deep-interview" || q.workflowGate.kind !== "question")) return;
522
630
  const cwd = this.session.cwd;
523
631
  const sessionId = this.session.getSessionId?.() ?? undefined;
524
632
  const statePath = deepInterviewStatePath(cwd, sessionId);
@@ -536,6 +644,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
536
644
  ambiguity: meta.ambiguity,
537
645
  selectedOptions,
538
646
  customInput,
647
+ // The Round 0 manifest is locked from the confirmation answer itself, and any
648
+ // later reduction is reviewed against that lock. Dropping them here leaves the
649
+ // recorder nothing to bind, so the "locked intent" is never actually locked.
650
+ intent_contract: "intent_contract" in meta ? meta.intent_contract : undefined,
651
+ intent_review: "intent_review" in meta ? meta.intent_review : undefined,
539
652
  },
540
653
  { sessionId },
541
654
  ).then(async () => {
@@ -553,7 +666,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
553
666
  ): Promise<AgentToolResult<AskToolDetails>> {
554
667
  await assertUltragoalAskAllowed(this.session.cwd);
555
668
  const gateEmitter = this.session.getWorkflowGateEmitter?.();
556
- const canUseWorkflowGate = !context?.hasUI && gateEmitter?.supportsRemoteGateAnswers() === true;
669
+ // A negotiated unattended run answers through workflow gates even when a UI
670
+ // exists; otherwise gates are the headless answer path only.
671
+ const canUseWorkflowGate =
672
+ gateEmitter?.supportsRemoteGateAnswers() === true &&
673
+ (gateEmitter.isUnattended?.() === true || !context?.hasUI);
557
674
 
558
675
  // Headless fallback: unattended workflow gates are the non-TUI answer path.
559
676
  if (!canUseWorkflowGate && (!context?.hasUI || !context.ui)) {
@@ -652,7 +769,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
652
769
 
653
770
  const askQuestion = async (
654
771
  q: AskParams["questions"][number],
655
- options?: { previous?: QuestionResult; navigation?: NavigationControls },
772
+ options?: {
773
+ previous?: QuestionResult;
774
+ navigation?: NavigationControls;
775
+ navigationLabel?: "Next" | "Done";
776
+ },
656
777
  ) => {
657
778
  const rawOptionLabels = q.options.map(o => o.label);
658
779
  // Unattended (#316/#323/G011): route the question through the workflow-gate
@@ -663,7 +784,12 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
663
784
  question: q.question,
664
785
  options: q.options,
665
786
  multi: q.multi,
787
+ // Interactive multi-select allows finishing with nothing selected
788
+ // ("Next"); the gate path must keep the same empty-answer semantics.
789
+ allowEmpty: q.multi === true,
790
+ navigationLabel: options?.navigationLabel,
666
791
  recommended: q.recommended,
792
+ workflowGate: q.workflowGate,
667
793
  deepInterview: q.deepInterview,
668
794
  };
669
795
  const answer = await gateEmitter.emitGate(questionToGate(gateQuestion));
@@ -672,6 +798,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
672
798
  optionLabels: rawOptionLabels,
673
799
  selectedOptions: decoded.selectedOptions,
674
800
  customInput: decoded.customInput,
801
+ clarificationQuestion: decoded.clarificationQuestion,
675
802
  navigation: undefined as NavigationControls | undefined,
676
803
  cancelled: false,
677
804
  timedOut: false,
@@ -716,7 +843,15 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
716
843
  return displayIndex >= 0 ? (rawOptionLabels[displayIndex] ?? selected) : selected;
717
844
  })
718
845
  : displaySelectedOptions;
719
- return { optionLabels: rawOptionLabels, selectedOptions, customInput, navigation, cancelled, timedOut };
846
+ return {
847
+ optionLabels: rawOptionLabels,
848
+ selectedOptions,
849
+ customInput,
850
+ clarificationQuestion: undefined as string | undefined,
851
+ navigation,
852
+ cancelled,
853
+ timedOut,
854
+ };
720
855
  } catch (error) {
721
856
  if (error instanceof Error && error.name === "AbortError") {
722
857
  throw new ToolAbortError("Ask input was cancelled");
@@ -727,8 +862,12 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
727
862
 
728
863
  if (params.questions.length === 1) {
729
864
  const [q] = params.questions;
730
- const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q);
865
+ const { optionLabels, selectedOptions, customInput, clarificationQuestion, cancelled, timedOut } =
866
+ await askQuestion(q);
731
867
 
868
+ if (clarificationQuestion !== undefined) {
869
+ return this.#clarificationResult(q, optionLabels, clarificationQuestion);
870
+ }
732
871
  if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) {
733
872
  context?.abort();
734
873
  throw new ToolAbortError("Ask tool was cancelled by the user");
@@ -777,11 +916,21 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
777
916
  optionLabels,
778
917
  selectedOptions,
779
918
  customInput,
919
+ clarificationQuestion,
780
920
  navigation: navAction,
781
921
  cancelled,
782
922
  timedOut,
783
- } = await askQuestion(q, { previous, navigation });
923
+ } = await askQuestion(q, {
924
+ previous,
925
+ navigation,
926
+ navigationLabel: questionIndex + 1 < params.questions.length ? "Next" : "Done",
927
+ });
784
928
 
929
+ // A clarification is an answer about the ask itself: stop the sequence and
930
+ // surface the question instead of treating the empty selection as a cancel.
931
+ if (clarificationQuestion !== undefined) {
932
+ return this.#clarificationResult(q, optionLabels, clarificationQuestion);
933
+ }
785
934
  if (cancelled && !timedOut) {
786
935
  context?.abort();
787
936
  throw new ToolAbortError("Ask tool was cancelled by the user");
@@ -824,6 +973,30 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
824
973
 
825
974
  return { content: [{ type: "text" as const, text: responseText }], details };
826
975
  }
976
+
977
+ /** A clarification answer surfaces the user's question instead of a selection. */
978
+ #clarificationResult(
979
+ q: AskParams["questions"][number],
980
+ optionLabels: string[],
981
+ clarificationQuestion: string,
982
+ ): AgentToolResult<AskToolDetails> {
983
+ return {
984
+ content: [
985
+ {
986
+ type: "text" as const,
987
+ text: `User asked a clarification question about the choices: ${clarificationQuestion}`,
988
+ },
989
+ ],
990
+ details: {
991
+ question: q.question,
992
+ options: optionLabels,
993
+ multi: q.multi ?? false,
994
+ selectedOptions: [],
995
+ customInput: undefined,
996
+ clarificationQuestion,
997
+ },
998
+ };
999
+ }
827
1000
  }
828
1001
 
829
1002
  // =============================================================================
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).");