@gajae-code/coding-agent 0.7.9 → 0.7.10
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 +22 -0
- package/README.md +2 -0
- package/dist/types/config/settings-schema.d.ts +10 -1
- package/dist/types/extensibility/extensions/types.d.ts +11 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +7 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -4
- package/dist/types/modes/components/hook-selector.d.ts +5 -0
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +20 -6
- package/dist/types/notifications/html-format.d.ts +2 -0
- package/dist/types/notifications/recent-activity.d.ts +5 -1
- package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
- package/dist/types/notifications/telegram-reference.d.ts +4 -0
- package/dist/types/notifications/threaded-render.d.ts +1 -0
- package/dist/types/tools/ask.d.ts +2 -0
- package/dist/types/utils/cmux-workspace.d.ts +23 -0
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/provider.d.ts +5 -0
- package/dist/types/web/search/render.d.ts +1 -0
- package/package.json +7 -7
- package/src/cli/web-search-cli.ts +3 -2
- package/src/config/settings-schema.ts +11 -1
- package/src/coordinator-mcp/server.ts +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +26 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -112
- package/src/extensibility/extensions/types.ts +11 -0
- package/src/gjc-runtime/launch-tmux.ts +63 -2
- package/src/gjc-runtime/ultragoal-guard.ts +139 -19
- package/src/gjc-runtime/ultragoal-runtime.ts +16 -124
- package/src/gjc-runtime/workflow-manifest.generated.json +0 -8
- package/src/gjc-runtime/workflow-manifest.ts +0 -1
- package/src/hooks/skill-state.ts +33 -73
- package/src/internal-urls/docs-index.generated.ts +2 -1
- package/src/modes/components/hook-selector.ts +21 -3
- package/src/modes/components/settings-selector.ts +47 -3
- package/src/modes/components/status-line/presets.ts +11 -0
- package/src/modes/controllers/extension-ui-controller.ts +18 -2
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +53 -9
- package/src/notifications/html-format.ts +75 -1
- package/src/notifications/index.ts +12 -1
- package/src/notifications/recent-activity.ts +32 -8
- package/src/notifications/telegram-daemon-control.ts +9 -0
- package/src/notifications/telegram-daemon.ts +67 -24
- package/src/notifications/telegram-reference.ts +36 -14
- package/src/notifications/threaded-render.ts +14 -6
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/sdk.ts +3 -5
- package/src/tools/ask.ts +136 -16
- package/src/utils/cmux-workspace.ts +106 -0
- package/src/utils/title-generator.ts +2 -0
- package/src/web/search/index.ts +22 -10
- package/src/web/search/provider.ts +15 -0
- package/src/web/search/render.ts +3 -0
|
@@ -58,6 +58,11 @@ export interface HookSelectorOptions {
|
|
|
58
58
|
optionLabel: string;
|
|
59
59
|
onSubmit: (text: string) => void;
|
|
60
60
|
};
|
|
61
|
+
clarificationInput?: {
|
|
62
|
+
optionLabel: string;
|
|
63
|
+
onSubmit: (text: string) => void;
|
|
64
|
+
allowEmpty?: boolean;
|
|
65
|
+
};
|
|
61
66
|
/**
|
|
62
67
|
* Autocomplete provider for the inline custom-input editor. When present,
|
|
63
68
|
* the "Other (type your own)" editor gains the same `@` file-link and `/`
|
|
@@ -338,6 +343,8 @@ export class HookSelectorComponent extends Container {
|
|
|
338
343
|
#outline: boolean;
|
|
339
344
|
#scrollTitleRows: number | undefined;
|
|
340
345
|
#customInput: { optionLabel: string; onSubmit: (text: string) => void } | undefined;
|
|
346
|
+
#clarificationInput: { optionLabel: string; onSubmit: (text: string) => void; allowEmpty?: boolean } | undefined;
|
|
347
|
+
#activeInput: { onSubmit: (text: string) => void; allowEmpty?: boolean } | undefined;
|
|
341
348
|
#inputArea: Container;
|
|
342
349
|
#inlineEditor: Editor | undefined;
|
|
343
350
|
#helpTextComponent: Text;
|
|
@@ -365,6 +372,7 @@ export class HookSelectorComponent extends Container {
|
|
|
365
372
|
this.#wrapFocused = opts?.wrapFocused === true;
|
|
366
373
|
this.#outline = opts?.outline === true;
|
|
367
374
|
this.#customInput = opts?.customInput;
|
|
375
|
+
this.#clarificationInput = opts?.clarificationInput;
|
|
368
376
|
this.#tui = opts?.tui;
|
|
369
377
|
this.#autocompleteProvider = opts?.autocompleteProvider;
|
|
370
378
|
|
|
@@ -500,7 +508,11 @@ export class HookSelectorComponent extends Container {
|
|
|
500
508
|
const selected = this.#options[this.#selectedIndex];
|
|
501
509
|
if (!selected) return;
|
|
502
510
|
if (this.#customInput && selected === this.#customInput.optionLabel) {
|
|
503
|
-
this.#enterInputMode();
|
|
511
|
+
this.#enterInputMode(this.#customInput);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (this.#clarificationInput && selected === this.#clarificationInput.optionLabel) {
|
|
515
|
+
this.#enterInputMode(this.#clarificationInput);
|
|
504
516
|
return;
|
|
505
517
|
}
|
|
506
518
|
this.#onSelectCallback(selected);
|
|
@@ -535,14 +547,19 @@ export class HookSelectorComponent extends Container {
|
|
|
535
547
|
return;
|
|
536
548
|
}
|
|
537
549
|
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
|
|
538
|
-
|
|
550
|
+
const text = editor.getExpandedText();
|
|
551
|
+
if (this.#activeInput?.allowEmpty === false && text.trim() === "") {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
this.#activeInput?.onSubmit(text);
|
|
539
555
|
return;
|
|
540
556
|
}
|
|
541
557
|
editor.handleInput(keyData);
|
|
542
558
|
}
|
|
543
559
|
|
|
544
|
-
#enterInputMode(): void {
|
|
560
|
+
#enterInputMode(input: { onSubmit: (text: string) => void; allowEmpty?: boolean }): void {
|
|
545
561
|
if (this.#inlineEditor) return;
|
|
562
|
+
this.#activeInput = input;
|
|
546
563
|
// Stop the auto-select countdown for good: the user is actively typing,
|
|
547
564
|
// matching the old behavior where the separate editor had no timeout.
|
|
548
565
|
if (this.#countdown) {
|
|
@@ -577,6 +594,7 @@ export class HookSelectorComponent extends Container {
|
|
|
577
594
|
#exitInputMode(): void {
|
|
578
595
|
if (!this.#inlineEditor) return;
|
|
579
596
|
this.#inlineEditor = undefined;
|
|
597
|
+
this.#activeInput = undefined;
|
|
580
598
|
this.#inputArea.clear();
|
|
581
599
|
this.#helpTextComponent.setText(theme.fg("dim", this.#baseHelpText));
|
|
582
600
|
this.invalidate();
|
|
@@ -164,6 +164,7 @@ class SelectSubmenu extends Container {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
const STATUS_LINE_CUSTOM_EDITOR_ID = "statusLine.customEditor";
|
|
167
|
+
const STATUS_LINE_USAGE_MODE_ID = "statusLine.usageMode";
|
|
167
168
|
const PUBLIC_STATUS_SEGMENTS = ALL_SEGMENT_IDS.filter(id => id !== "pi");
|
|
168
169
|
|
|
169
170
|
type StatusLineDraft = Required<
|
|
@@ -183,6 +184,8 @@ const USAGE_MODE_OPTIONS: SelectItem[] = [
|
|
|
183
184
|
{ value: "used", label: "Used" },
|
|
184
185
|
{ value: "remaining", label: "Remaining" },
|
|
185
186
|
];
|
|
187
|
+
const USAGE_MODE_VALUES = ["used", "remaining"] as const;
|
|
188
|
+
type UsageMode = (typeof USAGE_MODE_VALUES)[number];
|
|
186
189
|
|
|
187
190
|
function cloneSegmentOptions(options: StatusLineSegmentOptions | undefined): StatusLineSegmentOptions {
|
|
188
191
|
return mergeSegmentOptions(undefined, options);
|
|
@@ -223,6 +226,25 @@ function effectiveCustomSegments(
|
|
|
223
226
|
};
|
|
224
227
|
}
|
|
225
228
|
|
|
229
|
+
function getSavedUsageMode(): UsageMode {
|
|
230
|
+
const segmentOptions = settings.get("statusLine.segmentOptions") as StatusLineSegmentOptions;
|
|
231
|
+
return segmentOptions.usage?.mode === "remaining" ? "remaining" : "used";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function setSavedUsageMode(mode: string): StatusLineSegmentOptions {
|
|
235
|
+
const normalizedMode: UsageMode = mode === "remaining" ? "remaining" : "used";
|
|
236
|
+
const segmentOptions = settings.get("statusLine.segmentOptions") as StatusLineSegmentOptions;
|
|
237
|
+
const nextOptions: StatusLineSegmentOptions = {
|
|
238
|
+
...segmentOptions,
|
|
239
|
+
usage: {
|
|
240
|
+
...(segmentOptions.usage ?? {}),
|
|
241
|
+
mode: normalizedMode,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
settings.set("statusLine.segmentOptions", nextOptions as Record<string, unknown>);
|
|
245
|
+
return nextOptions;
|
|
246
|
+
}
|
|
247
|
+
|
|
226
248
|
function statusSegmentLabel(id: StatusLineSegmentId): string {
|
|
227
249
|
return id.replace(/_/g, " ");
|
|
228
250
|
}
|
|
@@ -844,9 +866,7 @@ export class SettingsSelectorComponent extends Container {
|
|
|
844
866
|
};
|
|
845
867
|
} else if (def.path === "statusLine.preset") {
|
|
846
868
|
onPreview = value => {
|
|
847
|
-
const presetDef = getPreset(
|
|
848
|
-
value as "default" | "minimal" | "compact" | "full" | "nerd" | "ascii" | "custom",
|
|
849
|
-
);
|
|
869
|
+
const presetDef = getPreset(value as StatusLinePreset);
|
|
850
870
|
this.callbacks.onStatusLinePreview?.({
|
|
851
871
|
preset: value as StatusLinePreset,
|
|
852
872
|
leftSegments: presetDef.leftSegments,
|
|
@@ -984,6 +1004,16 @@ export class SettingsSelectorComponent extends Container {
|
|
|
984
1004
|
10,
|
|
985
1005
|
getSettingsListTheme(),
|
|
986
1006
|
(id, newValue) => {
|
|
1007
|
+
if (id === STATUS_LINE_USAGE_MODE_ID) {
|
|
1008
|
+
const segmentOptions = setSavedUsageMode(newValue);
|
|
1009
|
+
this.callbacks.onChange("statusLine.segmentOptions", segmentOptions);
|
|
1010
|
+
if (tabId === "appearance") {
|
|
1011
|
+
this.#triggerStatusLinePreview();
|
|
1012
|
+
}
|
|
1013
|
+
this.#refreshCurrentTabItems(defs);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
987
1017
|
const def = defs.find(d => d.path === id);
|
|
988
1018
|
if (!def) return;
|
|
989
1019
|
|
|
@@ -1048,6 +1078,20 @@ export class SettingsSelectorComponent extends Container {
|
|
|
1048
1078
|
} else {
|
|
1049
1079
|
items.push(customEditorItem);
|
|
1050
1080
|
}
|
|
1081
|
+
{
|
|
1082
|
+
const usageModeItem: SettingItem = {
|
|
1083
|
+
id: STATUS_LINE_USAGE_MODE_ID,
|
|
1084
|
+
label: "Status Line Usage Mode",
|
|
1085
|
+
description: "Show provider quota in the status line as used or remaining.",
|
|
1086
|
+
currentValue: getSavedUsageMode(),
|
|
1087
|
+
values: [...USAGE_MODE_VALUES],
|
|
1088
|
+
};
|
|
1089
|
+
if (presetIndex >= 0) {
|
|
1090
|
+
items.splice(presetIndex + 2, 0, usageModeItem);
|
|
1091
|
+
} else {
|
|
1092
|
+
items.push(usageModeItem);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1051
1095
|
}
|
|
1052
1096
|
return items;
|
|
1053
1097
|
}
|
|
@@ -12,6 +12,17 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
12
12
|
},
|
|
13
13
|
},
|
|
14
14
|
|
|
15
|
+
"default-usage": {
|
|
16
|
+
leftSegments: ["model", "mode", "git", "pr", "path"],
|
|
17
|
+
rightSegments: ["session_name", "jobs", "token_rate", "usage", "context_pct", "cost"],
|
|
18
|
+
separator: "slash",
|
|
19
|
+
segmentOptions: {
|
|
20
|
+
model: { showThinkingLevel: true },
|
|
21
|
+
path: { abbreviate: true, maxLength: 32, stripWorkPrefix: true },
|
|
22
|
+
git: { showBranch: true, showStaged: true, showUnstaged: true, showUntracked: true },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
|
|
15
26
|
minimal: {
|
|
16
27
|
leftSegments: ["path", "git"],
|
|
17
28
|
rightSegments: ["session_name", "jobs", "mode", "context_pct"],
|
|
@@ -617,7 +617,8 @@ export class ExtensionUiController {
|
|
|
617
617
|
const listChromeRows = dialogOptions?.outline === true ? HOOK_SELECTOR_OUTLINE_ROWS : 0;
|
|
618
618
|
// Reserve rows for the inline custom-input editor so opening it doesn't
|
|
619
619
|
// push the scrollable title past the viewport into terminal scrollback.
|
|
620
|
-
const inlineInputRows =
|
|
620
|
+
const inlineInputRows =
|
|
621
|
+
dialogOptions?.customInput || dialogOptions?.clarificationInput ? HOOK_SELECTOR_INLINE_INPUT_ROWS : 0;
|
|
621
622
|
const availableTitleRows =
|
|
622
623
|
this.ctx.ui.terminal.rows - scrollOptionRows - listChromeRows - inlineInputRows - HOOK_SELECTOR_CHROME_ROWS;
|
|
623
624
|
const scrollTitleRows =
|
|
@@ -659,7 +660,10 @@ export class ExtensionUiController {
|
|
|
659
660
|
tui: this.ctx.ui,
|
|
660
661
|
// Share the main prompt editor's autocomplete provider so the
|
|
661
662
|
// inline "Other (type your own)" editor supports `@` file links.
|
|
662
|
-
autocompleteProvider:
|
|
663
|
+
autocompleteProvider:
|
|
664
|
+
dialogOptions?.customInput || dialogOptions?.clarificationInput
|
|
665
|
+
? this.ctx.editor.getAutocompleteProvider()
|
|
666
|
+
: undefined,
|
|
663
667
|
outline: dialogOptions?.outline,
|
|
664
668
|
wrapFocused: dialogOptions?.wrapFocused,
|
|
665
669
|
scrollTitleRows,
|
|
@@ -675,6 +679,18 @@ export class ExtensionUiController {
|
|
|
675
679
|
},
|
|
676
680
|
}
|
|
677
681
|
: undefined,
|
|
682
|
+
clarificationInput: dialogOptions?.clarificationInput
|
|
683
|
+
? {
|
|
684
|
+
optionLabel: dialogOptions.clarificationInput.optionLabel,
|
|
685
|
+
allowEmpty: dialogOptions.clarificationInput.allowEmpty,
|
|
686
|
+
onSubmit: text => {
|
|
687
|
+
const optionLabel = dialogOptions.clarificationInput?.optionLabel;
|
|
688
|
+
this.hideHookSelector();
|
|
689
|
+
dialogOptions.clarificationInput?.onSubmit(text);
|
|
690
|
+
finish(optionLabel);
|
|
691
|
+
},
|
|
692
|
+
}
|
|
693
|
+
: undefined,
|
|
678
694
|
},
|
|
679
695
|
);
|
|
680
696
|
// Detach (not dispose) the reusable editor before mounting the transient hook UI, so the
|
|
@@ -53,6 +53,7 @@ export interface AskGateResult {
|
|
|
53
53
|
multi: boolean;
|
|
54
54
|
selectedOptions: string[];
|
|
55
55
|
customInput?: string;
|
|
56
|
+
clarificationQuestion?: string;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
@@ -61,12 +62,15 @@ export interface AskGateResult {
|
|
|
61
62
|
* `selected` are picked option labels; free text is conveyed by `other: true`
|
|
62
63
|
* plus `custom`, encoded separately from `selected` so a real option whose label
|
|
63
64
|
* happens to equal the display sentinel can never collide with the free-text path.
|
|
65
|
+
*
|
|
66
|
+
* `action: "clarify"` is a non-answer. It lets a Deep Interview user ask about
|
|
67
|
+
* the presented choices without advancing the round, scoring ambiguity, or
|
|
68
|
+
* recording selection state.
|
|
64
69
|
*/
|
|
65
|
-
export
|
|
66
|
-
selected: string[];
|
|
67
|
-
other?:
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
+
export type DeepInterviewGateAnswer =
|
|
71
|
+
| { selected: string[]; other?: false; custom?: undefined; action?: "answer" }
|
|
72
|
+
| { selected: string[]; other: true; custom: string; action?: "answer" }
|
|
73
|
+
| { action: "clarify"; question: string };
|
|
70
74
|
|
|
71
75
|
export class DeepInterviewGateError extends Error {
|
|
72
76
|
constructor(
|
|
@@ -75,6 +79,7 @@ export class DeepInterviewGateError extends Error {
|
|
|
75
79
|
| "unknown_option"
|
|
76
80
|
| "multi_not_allowed"
|
|
77
81
|
| "missing_custom"
|
|
82
|
+
| "missing_clarification"
|
|
78
83
|
| "empty_selection"
|
|
79
84
|
| "duplicate_selection",
|
|
80
85
|
message: string,
|
|
@@ -127,13 +132,22 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
|
|
|
127
132
|
selected: selectedBase,
|
|
128
133
|
other: { type: "boolean", description: "set true to provide a free-text answer in `custom`" },
|
|
129
134
|
custom: { type: "string", minLength: 1, description: "free-text answer; required when `other` is true" },
|
|
135
|
+
action: {
|
|
136
|
+
type: "string",
|
|
137
|
+
enum: ["answer", "clarify"],
|
|
138
|
+
description: "set to `clarify` to ask about the choices without answering the round",
|
|
139
|
+
},
|
|
140
|
+
question: {
|
|
141
|
+
type: "string",
|
|
142
|
+
minLength: 1,
|
|
143
|
+
description: "clarification question; required when action is `clarify`",
|
|
144
|
+
},
|
|
130
145
|
},
|
|
131
|
-
required: ["selected"],
|
|
132
146
|
additionalProperties: false,
|
|
133
147
|
anyOf: [
|
|
134
148
|
{
|
|
135
149
|
type: "object",
|
|
136
|
-
properties: { selected: selectedOnly, other: { const: false } },
|
|
150
|
+
properties: { selected: selectedOnly, other: { const: false }, action: { const: "answer" } },
|
|
137
151
|
required: ["selected"],
|
|
138
152
|
additionalProperties: false,
|
|
139
153
|
},
|
|
@@ -143,10 +157,20 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
|
|
|
143
157
|
selected: selectedWithOther,
|
|
144
158
|
other: { const: true },
|
|
145
159
|
custom: { type: "string", minLength: 1 },
|
|
160
|
+
action: { const: "answer" },
|
|
146
161
|
},
|
|
147
162
|
required: ["selected", "other", "custom"],
|
|
148
163
|
additionalProperties: false,
|
|
149
164
|
},
|
|
165
|
+
{
|
|
166
|
+
type: "object",
|
|
167
|
+
properties: {
|
|
168
|
+
action: { const: "clarify" },
|
|
169
|
+
question: { type: "string", minLength: 1 },
|
|
170
|
+
},
|
|
171
|
+
required: ["action", "question"],
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
},
|
|
150
174
|
],
|
|
151
175
|
};
|
|
152
176
|
}
|
|
@@ -185,6 +209,7 @@ export function questionToGate(question: AskGateQuestion): OpenGateInput {
|
|
|
185
209
|
multi: question.multi ?? false,
|
|
186
210
|
options: labels,
|
|
187
211
|
other_option: GATE_OTHER_OPTION,
|
|
212
|
+
clarification_action: "clarify",
|
|
188
213
|
...(question.deepInterview
|
|
189
214
|
? structuredDeepInterviewState(question.deepInterview)
|
|
190
215
|
: deepInterviewQuestionState(question.question)),
|
|
@@ -195,8 +220,14 @@ export function questionToGate(question: AskGateQuestion): OpenGateInput {
|
|
|
195
220
|
|
|
196
221
|
function isAnswer(value: unknown): value is DeepInterviewGateAnswer {
|
|
197
222
|
if (typeof value !== "object" || value === null) return false;
|
|
198
|
-
const v = value as
|
|
223
|
+
const v = value as Record<string, unknown>;
|
|
224
|
+
if (v.action === "clarify") {
|
|
225
|
+
return (
|
|
226
|
+
typeof v.question === "string" && v.selected === undefined && v.other === undefined && v.custom === undefined
|
|
227
|
+
);
|
|
228
|
+
}
|
|
199
229
|
return (
|
|
230
|
+
(v.action === undefined || v.action === "answer") &&
|
|
200
231
|
Array.isArray(v.selected) &&
|
|
201
232
|
v.selected.every(s => typeof s === "string") &&
|
|
202
233
|
(v.other === undefined || typeof v.other === "boolean") &&
|
|
@@ -214,11 +245,24 @@ export function gateAnswerToResult(question: AskGateQuestion, answer: unknown):
|
|
|
214
245
|
if (!isAnswer(answer)) {
|
|
215
246
|
throw new DeepInterviewGateError(
|
|
216
247
|
"invalid_answer_shape",
|
|
217
|
-
|
|
248
|
+
'answer must be an answer ({ selected: string[]; other?: boolean; custom?: string }) or clarification ({ action: "clarify"; question: string })',
|
|
218
249
|
);
|
|
219
250
|
}
|
|
220
251
|
const labels = question.options.map(o => o.label);
|
|
221
252
|
const multi = question.multi ?? false;
|
|
253
|
+
if (answer.action === "clarify") {
|
|
254
|
+
if (answer.question.trim() === "") {
|
|
255
|
+
throw new DeepInterviewGateError("missing_clarification", "clarification question is required");
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
id: question.id,
|
|
259
|
+
question: question.question,
|
|
260
|
+
options: labels,
|
|
261
|
+
multi,
|
|
262
|
+
selectedOptions: [],
|
|
263
|
+
clarificationQuestion: answer.question,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
222
266
|
const valid = new Set(labels);
|
|
223
267
|
for (const sel of answer.selected) {
|
|
224
268
|
if (!valid.has(sel)) throw new DeepInterviewGateError("unknown_option", `unknown option: ${sel}`);
|
|
@@ -248,6 +248,8 @@ interface Token {
|
|
|
248
248
|
value: string;
|
|
249
249
|
/** Tag name if this token opens a tag, else undefined. */
|
|
250
250
|
open?: string;
|
|
251
|
+
/** Exact opening tag text, including attributes, for safe chunk reopening. */
|
|
252
|
+
openTag?: string;
|
|
251
253
|
/** Tag name if this token closes a tag, else undefined. */
|
|
252
254
|
close?: string;
|
|
253
255
|
}
|
|
@@ -266,8 +268,10 @@ function tokenize(html: string): Token[] {
|
|
|
266
268
|
const openMatch = /^<([a-z-]+)(?:\s[^>]*)?>$/i.exec(raw);
|
|
267
269
|
const token: Token = { value: raw };
|
|
268
270
|
if (close && ALLOWED_TAGS.has(close[1]!.toLowerCase())) token.close = close[1]!.toLowerCase();
|
|
269
|
-
else if (openMatch && ALLOWED_TAGS.has(openMatch[1]!.toLowerCase()))
|
|
271
|
+
else if (openMatch && ALLOWED_TAGS.has(openMatch[1]!.toLowerCase())) {
|
|
270
272
|
token.open = openMatch[1]!.toLowerCase();
|
|
273
|
+
token.openTag = raw;
|
|
274
|
+
}
|
|
271
275
|
tokens.push(token);
|
|
272
276
|
i = end + 1;
|
|
273
277
|
continue;
|
|
@@ -330,6 +334,76 @@ export function truncateTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIM
|
|
|
330
334
|
return out + closersFor(stack) + effectiveMarker;
|
|
331
335
|
}
|
|
332
336
|
|
|
337
|
+
interface OpenTag {
|
|
338
|
+
name: string;
|
|
339
|
+
tag: string;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Split a finished Telegram HTML message into ordered chunks without splitting tags or entities. */
|
|
343
|
+
export function splitTelegramHtml(message: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
|
|
344
|
+
if (message.length <= max) return [message];
|
|
345
|
+
const chunks: string[] = [];
|
|
346
|
+
const tokens = tokenize(message);
|
|
347
|
+
const stack: OpenTag[] = [];
|
|
348
|
+
let out = "";
|
|
349
|
+
let chunkHasBody = false;
|
|
350
|
+
|
|
351
|
+
const closersFor = (s: OpenTag[]): string =>
|
|
352
|
+
s
|
|
353
|
+
.map(t => `</${t.name}>`)
|
|
354
|
+
.reverse()
|
|
355
|
+
.join("");
|
|
356
|
+
const openersFor = (s: OpenTag[]): string => s.map(t => t.tag).join("");
|
|
357
|
+
const minimumChunkLengthFor = (s: OpenTag[]): number => openersFor(s).length + closersFor(s).length + 1;
|
|
358
|
+
const flush = (): void => {
|
|
359
|
+
if (!chunkHasBody) return;
|
|
360
|
+
chunks.push(out + closersFor(stack));
|
|
361
|
+
out = openersFor(stack);
|
|
362
|
+
chunkHasBody = false;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const updateStackForClose = (name: string): boolean => {
|
|
366
|
+
const idx = stack.findLastIndex(t => t.name === name);
|
|
367
|
+
if (idx === -1) return false;
|
|
368
|
+
stack.splice(idx, 1);
|
|
369
|
+
return true;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
for (const token of tokens) {
|
|
373
|
+
if (token.open) {
|
|
374
|
+
const nextStack = [...stack, { name: token.open, tag: token.openTag ?? token.value }];
|
|
375
|
+
if (minimumChunkLengthFor(nextStack) > max) continue;
|
|
376
|
+
if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
|
|
377
|
+
if (out.length + token.value.length + closersFor(nextStack).length > max) continue;
|
|
378
|
+
out += token.value;
|
|
379
|
+
chunkHasBody = true;
|
|
380
|
+
stack.push({ name: token.open, tag: token.openTag ?? token.value });
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (token.close) {
|
|
385
|
+
const idx = stack.findLastIndex(t => t.name === token.close);
|
|
386
|
+
if (idx === -1) continue;
|
|
387
|
+
const nextStack = stack.toSpliced(idx, 1);
|
|
388
|
+
if (chunkHasBody && out.length + token.value.length + closersFor(nextStack).length > max) flush();
|
|
389
|
+
if (out.length + token.value.length + closersFor(nextStack).length > max) {
|
|
390
|
+
updateStackForClose(token.close);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
out += token.value;
|
|
394
|
+
chunkHasBody = true;
|
|
395
|
+
updateStackForClose(token.close);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (chunkHasBody && out.length + token.value.length + closersFor(stack).length > max) flush();
|
|
400
|
+
out += token.value;
|
|
401
|
+
chunkHasBody = true;
|
|
402
|
+
}
|
|
403
|
+
if (chunkHasBody) chunks.push(out + closersFor(stack));
|
|
404
|
+
return chunks;
|
|
405
|
+
}
|
|
406
|
+
|
|
333
407
|
/** Finalize an optional message: undefined passthrough, else safe-truncate. */
|
|
334
408
|
export function finalizeTelegramHtml(message?: string): string | undefined {
|
|
335
409
|
if (message === undefined) return undefined;
|
|
@@ -297,6 +297,15 @@ function buildIdentity(
|
|
|
297
297
|
return { repo, branch, machine: os.hostname(), title: sessionName };
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/** Compact cwd label for remote session identity; never emits the full host path by default. */
|
|
301
|
+
function compactCwd(cwd: string): string | undefined {
|
|
302
|
+
const home = os.homedir();
|
|
303
|
+
const resolved = path.resolve(cwd);
|
|
304
|
+
if (resolved === home) return "~";
|
|
305
|
+
const base = path.basename(resolved);
|
|
306
|
+
return base || path.parse(resolved).root || undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
300
309
|
const execFileAsync = promisify(execFile);
|
|
301
310
|
|
|
302
311
|
/** Best-effort working-tree diff stat for the context update (no throw). */
|
|
@@ -973,7 +982,8 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
973
982
|
const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
|
|
974
983
|
const modelId = model?.id;
|
|
975
984
|
void readGitDiffStat(ctx.cwd).then(diff => {
|
|
976
|
-
|
|
985
|
+
const cwd = compactCwd(ctx.cwd);
|
|
986
|
+
if (!diff && !tokenUsage && !modelId && !cwd) return;
|
|
977
987
|
try {
|
|
978
988
|
rt.server.pushFrame(
|
|
979
989
|
JSON.stringify({
|
|
@@ -982,6 +992,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
|
|
|
982
992
|
tokenUsage,
|
|
983
993
|
model: modelId,
|
|
984
994
|
diff,
|
|
995
|
+
cwd,
|
|
985
996
|
}),
|
|
986
997
|
);
|
|
987
998
|
} catch (e) {
|
|
@@ -25,6 +25,8 @@ export interface RecentSessionEntry {
|
|
|
25
25
|
mtimeMs: number;
|
|
26
26
|
/** True when a terminal breadcrumb points at this session file. */
|
|
27
27
|
currentTerminal?: boolean;
|
|
28
|
+
/** True when this history is an internal helper/sub-agent session. */
|
|
29
|
+
internal?: boolean;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export interface RecentActivityDeps {
|
|
@@ -34,17 +36,18 @@ export interface RecentActivityDeps {
|
|
|
34
36
|
breadcrumbPaths?: string[];
|
|
35
37
|
/** Max entries to return (default 20). */
|
|
36
38
|
limit?: number;
|
|
39
|
+
/** Include internal helper/sub-agent sessions (default true). */
|
|
40
|
+
includeInternal?: boolean;
|
|
37
41
|
/** Injection seam for tests. */
|
|
38
|
-
|
|
42
|
+
readInitialLines?: (file: string, maxLines: number) => string[];
|
|
39
43
|
}
|
|
40
44
|
|
|
41
|
-
function
|
|
45
|
+
function defaultReadInitialLines(file: string, maxLines: number): string[] {
|
|
42
46
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
return nl === -1 ? buf : buf.slice(0, nl);
|
|
47
|
+
const lines = fs.readFileSync(file, "utf8").split("\n");
|
|
48
|
+
return lines.slice(0, maxLines);
|
|
46
49
|
} catch {
|
|
47
|
-
return
|
|
50
|
+
return [];
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -65,6 +68,22 @@ function headerMeta(line: string | undefined): { id?: string; path?: string; bra
|
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/** Detect task-tool helper sessions from the durable early session_init metadata entry. */
|
|
72
|
+
function isInternalSession(lines: readonly string[]): boolean {
|
|
73
|
+
for (const line of lines.slice(1)) {
|
|
74
|
+
if (!line.trim()) continue;
|
|
75
|
+
try {
|
|
76
|
+
const obj = JSON.parse(line) as unknown;
|
|
77
|
+
if (typeof obj === "object" && obj !== null && (obj as { type?: unknown }).type === "session_init") {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore malformed JSONL entries; classification is best-effort.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
/**
|
|
69
88
|
* The authoritative session id for a history file: the header `id` when present,
|
|
70
89
|
* else the filename stem with a leading `<timestamp>_` prefix stripped (matching
|
|
@@ -85,7 +104,8 @@ function sessionIdForFile(stem: string, headerId: string | undefined): string {
|
|
|
85
104
|
*/
|
|
86
105
|
export function listRecentSessions(deps: RecentActivityDeps): RecentSessionEntry[] {
|
|
87
106
|
const limit = deps.limit ?? 20;
|
|
88
|
-
const
|
|
107
|
+
const includeInternal = deps.includeInternal ?? true;
|
|
108
|
+
const readInitialLines = deps.readInitialLines ?? defaultReadInitialLines;
|
|
89
109
|
const breadcrumbs = new Set((deps.breadcrumbPaths ?? []).map(p => path.resolve(p)));
|
|
90
110
|
|
|
91
111
|
let projectDirs: string[];
|
|
@@ -114,7 +134,10 @@ export function listRecentSessions(deps: RecentActivityDeps): RecentSessionEntry
|
|
|
114
134
|
} catch {
|
|
115
135
|
continue;
|
|
116
136
|
}
|
|
117
|
-
const
|
|
137
|
+
const initialLines = readInitialLines(file, 8);
|
|
138
|
+
const meta = headerMeta(initialLines[0]);
|
|
139
|
+
const internal = isInternalSession(initialLines);
|
|
140
|
+
if (internal && !includeInternal) continue;
|
|
118
141
|
entries.push({
|
|
119
142
|
sessionId: sessionIdForFile(name.slice(0, -".jsonl".length), meta.id),
|
|
120
143
|
path: meta.path,
|
|
@@ -123,6 +146,7 @@ export function listRecentSessions(deps: RecentActivityDeps): RecentSessionEntry
|
|
|
123
146
|
sessionStateFile: file,
|
|
124
147
|
mtimeMs,
|
|
125
148
|
currentTerminal: breadcrumbs.has(path.resolve(file)) || undefined,
|
|
149
|
+
internal: internal || undefined,
|
|
126
150
|
});
|
|
127
151
|
}
|
|
128
152
|
}
|
|
@@ -97,6 +97,14 @@ export interface TelegramDaemonControlDeps {
|
|
|
97
97
|
sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
|
|
98
98
|
spawn?: TelegramDaemonDeps["spawn"];
|
|
99
99
|
execPath?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Stable process id encoded into freshly-spawned daemon owner ids.
|
|
102
|
+
*
|
|
103
|
+
* The daemon-internal entrypoint rejects numeric owner ids whose process is
|
|
104
|
+
* already gone. `gjc daemon reload` is a short-lived CLI process, so using its
|
|
105
|
+
* own pid can race the child startup and make the replacement exit immediately.
|
|
106
|
+
*/
|
|
107
|
+
ownerPid?: number;
|
|
100
108
|
randomId?: () => string;
|
|
101
109
|
sleep?: (ms: number) => Promise<void>;
|
|
102
110
|
waitStepMs?: number;
|
|
@@ -188,6 +196,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
|
|
|
188
196
|
fs: this.deps.fs,
|
|
189
197
|
now: this.deps.now,
|
|
190
198
|
pidAlive: this.deps.pidAlive,
|
|
199
|
+
pid: this.deps.ownerPid ?? process.ppid,
|
|
191
200
|
spawn: this.deps.spawn,
|
|
192
201
|
execPath: this.deps.execPath,
|
|
193
202
|
randomId: this.deps.randomId,
|