@sayknow-cli/coding-agent 0.5.0 → 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.
- package/CHANGELOG.md +157 -0
- package/dist/types/commands/session.d.ts +7 -0
- package/dist/types/config/telegram-autostart.d.ts +9 -1
- package/dist/types/modes/components/pet-capability.d.ts +8 -7
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -0
- package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
- package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
- package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
- package/dist/types/skc-runtime/session-restore.d.ts +99 -0
- package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
- package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
- package/dist/types/tools/ask.d.ts +164 -4
- package/package.json +10 -7
- package/src/commands/session.ts +88 -2
- package/src/config/model-registry.ts +12 -0
- package/src/config/telegram-autostart.ts +11 -4
- package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +1 -1
- package/src/modes/components/pet-capability.ts +22 -13
- package/src/modes/components/pet-selector.ts +1 -1
- package/src/modes/components/sayknow-pet-widget.ts +41 -7
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
- package/src/notifications/lifecycle-control-runtime.ts +258 -179
- package/src/prompts/system/eager-todo.md +2 -0
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/prompts/system/system-prompt.md +4 -2
- package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
- package/src/session/agent-session.ts +31 -11
- package/src/skc-runtime/boot-generation.ts +172 -0
- package/src/skc-runtime/launch-tmux.ts +219 -41
- package/src/skc-runtime/session-restore-runtime.ts +120 -0
- package/src/skc-runtime/session-restore.ts +296 -0
- package/src/skc-runtime/session-state-sidecar.ts +41 -0
- package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
- package/src/skc-runtime/tmux-sessions.ts +284 -108
- package/src/slash-commands/builtin-registry.ts +9 -4
- package/src/tools/ask.ts +183 -10
- 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
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
61
|
-
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
|
-
|
|
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?: {
|
|
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 {
|
|
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 } =
|
|
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, {
|
|
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
|
-
|
|
187
|
-
const
|
|
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).");
|