@gajae-code/coding-agent 0.7.8 → 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.
Files changed (92) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +8 -0
  3. package/dist/types/config/keybindings.d.ts +1 -1
  4. package/dist/types/config/model-profile-activation.d.ts +19 -2
  5. package/dist/types/config/model-profiles.d.ts +1 -0
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/settings-schema.d.ts +12 -3
  8. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  9. package/dist/types/gjc-runtime/launch-tmux.d.ts +19 -3
  10. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -1
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -4
  12. package/dist/types/modes/components/hook-input.d.ts +1 -0
  13. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  14. package/dist/types/modes/components/model-selector.d.ts +9 -0
  15. package/dist/types/modes/components/status-line.d.ts +3 -0
  16. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +3 -1
  18. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +41 -7
  19. package/dist/types/modes/types.d.ts +3 -1
  20. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  21. package/dist/types/notifications/html-format.d.ts +2 -0
  22. package/dist/types/notifications/recent-activity.d.ts +5 -1
  23. package/dist/types/notifications/telegram-daemon-cli.d.ts +8 -2
  24. package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
  25. package/dist/types/notifications/telegram-daemon.d.ts +1 -10
  26. package/dist/types/notifications/telegram-reference.d.ts +4 -0
  27. package/dist/types/notifications/threaded-render.d.ts +1 -0
  28. package/dist/types/tools/ask.d.ts +26 -0
  29. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  30. package/dist/types/utils/cmux-workspace.d.ts +23 -0
  31. package/dist/types/web/search/index.d.ts +1 -1
  32. package/dist/types/web/search/provider.d.ts +5 -0
  33. package/dist/types/web/search/render.d.ts +1 -0
  34. package/package.json +7 -7
  35. package/src/cli/notify-cli.ts +1 -1
  36. package/src/cli/web-search-cli.ts +3 -2
  37. package/src/cli.ts +1 -0
  38. package/src/config/keybindings.ts +1 -1
  39. package/src/config/model-profile-activation.ts +92 -11
  40. package/src/config/model-profiles.ts +15 -2
  41. package/src/config/model-registry.ts +81 -5
  42. package/src/config/settings-schema.ts +14 -3
  43. package/src/coordinator-mcp/server.ts +4 -2
  44. package/src/deep-interview/render-middleware.ts +1 -0
  45. package/src/defaults/gjc/skills/deep-interview/SKILL.md +40 -8
  46. package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
  47. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -112
  48. package/src/extensibility/extensions/types.ts +11 -0
  49. package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
  50. package/src/gjc-runtime/launch-tmux.ts +366 -48
  51. package/src/gjc-runtime/team-runtime.ts +69 -4
  52. package/src/gjc-runtime/ultragoal-guard.ts +150 -24
  53. package/src/gjc-runtime/ultragoal-runtime.ts +16 -124
  54. package/src/gjc-runtime/workflow-manifest.generated.json +0 -8
  55. package/src/gjc-runtime/workflow-manifest.ts +0 -1
  56. package/src/hooks/skill-state.ts +33 -73
  57. package/src/internal-urls/docs-index.generated.ts +8 -7
  58. package/src/modes/components/custom-model-preset-wizard.ts +2 -4
  59. package/src/modes/components/hook-editor.ts +7 -4
  60. package/src/modes/components/hook-input.ts +4 -0
  61. package/src/modes/components/hook-selector.ts +21 -3
  62. package/src/modes/components/model-selector.ts +84 -13
  63. package/src/modes/components/settings-selector.ts +83 -24
  64. package/src/modes/components/status-line/presets.ts +11 -0
  65. package/src/modes/components/status-line/segments.ts +10 -2
  66. package/src/modes/components/status-line.ts +1 -0
  67. package/src/modes/controllers/extension-ui-controller.ts +20 -2
  68. package/src/modes/controllers/selector-controller.ts +130 -7
  69. package/src/modes/interactive-mode.ts +7 -2
  70. package/src/modes/shared/agent-wire/deep-interview-gate.ts +64 -13
  71. package/src/modes/types.ts +6 -1
  72. package/src/notifications/daemon-paths.ts +22 -0
  73. package/src/notifications/html-format.ts +75 -1
  74. package/src/notifications/index.ts +12 -1
  75. package/src/notifications/recent-activity.ts +32 -8
  76. package/src/notifications/telegram-daemon-cli.ts +13 -4
  77. package/src/notifications/telegram-daemon-control.ts +9 -0
  78. package/src/notifications/telegram-daemon.ts +69 -45
  79. package/src/notifications/telegram-reference.ts +36 -14
  80. package/src/notifications/threaded-render.ts +14 -6
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/sdk.ts +13 -6
  83. package/src/session/agent-session.ts +10 -1
  84. package/src/tools/ask.ts +147 -17
  85. package/src/tools/eval.ts +2 -2
  86. package/src/tools/render-utils.ts +2 -0
  87. package/src/tools/ultragoal-ask-guard.ts +39 -5
  88. package/src/utils/cmux-workspace.ts +106 -0
  89. package/src/utils/title-generator.ts +2 -0
  90. package/src/web/search/index.ts +22 -10
  91. package/src/web/search/provider.ts +15 -0
  92. package/src/web/search/render.ts +3 -0
@@ -4,8 +4,14 @@ import type { OAuthProvider } from "@gajae-code/ai/utils/oauth/types";
4
4
  import type { Component, OverlayHandle } from "@gajae-code/tui";
5
5
  import { Input, Loader, Spacer, Text } from "@gajae-code/tui";
6
6
  import { getAgentDbPath, getProjectDir } from "@gajae-code/utils";
7
- import { activateModelProfile, materializeActiveModelProfileAssignment } from "../../config/model-profile-activation";
8
- import { recommendModelProfileForProvider } from "../../config/model-profiles";
7
+ import {
8
+ activateModelProfile,
9
+ type MaterializeModelProfileForDeletionResult,
10
+ materializeActiveModelProfileAssignment,
11
+ materializeModelProfileForDeletion,
12
+ restoreMaterializedModelProfileForDeletion,
13
+ } from "../../config/model-profile-activation";
14
+ import { formatModelProfileDisplayLabel, recommendModelProfileForProvider } from "../../config/model-profiles";
9
15
  import { GJC_MODEL_ASSIGNMENT_TARGETS } from "../../config/model-registry";
10
16
  import { formatModelSelectorValue } from "../../config/model-resolver";
11
17
  import type { ModelProfileConfig } from "../../config/models-config-schema";
@@ -39,7 +45,11 @@ import {
39
45
  CREDENTIAL_AUTO_IMPORT_ROTATION_WARNING,
40
46
  runExternalCredentialAutoImport,
41
47
  } from "../../setup/credential-auto-import";
42
- import { filterAutoImportOAuthCredentials, formatDiscoverySummary } from "../../setup/credential-import";
48
+ import {
49
+ filterAutoImportOAuthCredentials,
50
+ formatDiscoverySummary,
51
+ type ImportableCredential,
52
+ } from "../../setup/credential-import";
43
53
  import {
44
54
  MODEL_ONBOARDING_API_PROVIDER_COMMAND,
45
55
  MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND,
@@ -248,7 +258,7 @@ export class SelectorController {
248
258
  const profile = await this.ctx.session.modelRegistry.saveCustomModelProfile(input.name, input.profile);
249
259
  await this.ctx.session.modelRegistry.refresh("offline");
250
260
  await this.ctx.notifyConfigChanged?.();
251
- this.ctx.showStatus(`Custom model preset created: ${profile.displayName ?? profile.name}`);
261
+ this.ctx.showStatus(`Custom model preset created: ${formatModelProfileDisplayLabel(profile)}`);
252
262
  done();
253
263
  this.ctx.ui.requestRender();
254
264
  } catch (err) {
@@ -271,6 +281,106 @@ export class SelectorController {
271
281
  });
272
282
  }
273
283
 
284
+ async #renameCustomModelPreset(profileName: string, modelSelector: ModelSelectorComponent): Promise<void> {
285
+ const profile = this.ctx.session.modelRegistry.getModelProfile(profileName);
286
+ const currentName = profile ? formatModelProfileDisplayLabel(profile) : profileName;
287
+ const input = await this.ctx.showHookInput(`Rename custom model preset: ${currentName}`, undefined, undefined, {
288
+ initialValue: currentName,
289
+ });
290
+ if (input === undefined) {
291
+ this.ctx.showStatus("Preset rename cancelled.");
292
+ this.ctx.ui.requestRender();
293
+ return;
294
+ }
295
+ try {
296
+ const renamed = await this.ctx.session.modelRegistry.renameCustomModelProfile(profileName, input);
297
+ await this.ctx.session.modelRegistry.refresh("offline");
298
+ await this.ctx.notifyConfigChanged?.();
299
+ modelSelector.refreshPresetProfiles(renamed.name);
300
+ this.ctx.showStatus(`Custom model preset renamed: ${formatModelProfileDisplayLabel(renamed)}`);
301
+ this.ctx.ui.requestRender();
302
+ } catch (err) {
303
+ this.ctx.showError(`Preset rename failed: ${err instanceof Error ? err.message : String(err)}`);
304
+ }
305
+ }
306
+
307
+ async #deleteCustomModelPreset(profileName: string, modelSelector: ModelSelectorComponent): Promise<void> {
308
+ const profile = this.ctx.session.modelRegistry.getModelProfile(profileName);
309
+ const profileLabel = profile ? formatModelProfileDisplayLabel(profile) : profileName;
310
+ const confirmed = await this.ctx.showHookConfirm(
311
+ `Delete custom model preset: ${profileLabel}`,
312
+ "This removes the preset entry after preserving current role model settings when this preset is active/default.",
313
+ );
314
+ if (!confirmed) {
315
+ this.ctx.showStatus("Preset delete cancelled.");
316
+ this.ctx.ui.requestRender();
317
+ return;
318
+ }
319
+
320
+ const activeProfile = this.ctx.session.getActiveModelProfile?.();
321
+ const defaultProfile = this.ctx.settings.get("modelProfile.default");
322
+ let snapshot: MaterializeModelProfileForDeletionResult | undefined;
323
+ let deletedProfile: ModelProfileConfig | undefined;
324
+ const refreshSelectorState = (refreshedProfileName?: string): void => {
325
+ modelSelector.refreshRoleAssignments({
326
+ currentModel: this.ctx.session.model,
327
+ currentThinkingLevel: this.ctx.session.thinkingLevel,
328
+ activeModelProfile:
329
+ this.ctx.session.getActiveModelProfile?.() ?? this.ctx.settings.get("modelProfile.default"),
330
+ });
331
+ modelSelector.refreshPresetProfiles(refreshedProfileName);
332
+ };
333
+ try {
334
+ if (activeProfile === profileName || defaultProfile === profileName) {
335
+ snapshot = await materializeModelProfileForDeletion({
336
+ session: this.ctx.session,
337
+ modelRegistry: this.ctx.session.modelRegistry,
338
+ settings: this.ctx.settings,
339
+ profileName,
340
+ });
341
+ }
342
+ deletedProfile = await this.ctx.session.modelRegistry.deleteCustomModelProfile(profileName);
343
+ await this.ctx.session.modelRegistry.refresh("offline");
344
+ await this.ctx.notifyConfigChanged?.();
345
+ refreshSelectorState();
346
+ this.ctx.showStatus(`Custom model preset deleted: ${profileLabel}`);
347
+ this.ctx.ui.requestRender();
348
+ } catch (err) {
349
+ let presetRestoreError: unknown;
350
+ if (deletedProfile) {
351
+ try {
352
+ await this.ctx.session.modelRegistry.saveCustomModelProfile(profileName, deletedProfile);
353
+ await this.ctx.session.modelRegistry.refresh("offline");
354
+ } catch (restoreErr) {
355
+ presetRestoreError = restoreErr;
356
+ }
357
+ }
358
+ if (snapshot) {
359
+ try {
360
+ await restoreMaterializedModelProfileForDeletion({
361
+ settings: this.ctx.settings,
362
+ session: this.ctx.session,
363
+ snapshot,
364
+ });
365
+ } catch (restoreErr) {
366
+ refreshSelectorState(deletedProfile ? profileName : undefined);
367
+ this.ctx.showError(
368
+ `Preset delete failed and settings rollback failed: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`,
369
+ );
370
+ return;
371
+ }
372
+ }
373
+ if (deletedProfile) refreshSelectorState(profileName);
374
+ if (presetRestoreError) {
375
+ this.ctx.showError(
376
+ `Preset delete failed and preset restore failed: ${presetRestoreError instanceof Error ? presetRestoreError.message : String(presetRestoreError)}`,
377
+ );
378
+ return;
379
+ }
380
+ this.ctx.showError(`Preset delete failed: ${err instanceof Error ? err.message : String(err)}`);
381
+ }
382
+ }
383
+
274
384
  showCustomProviderWizard(): void {
275
385
  this.showSelector(done => {
276
386
  let wizard: CustomProviderWizardComponent;
@@ -693,7 +803,20 @@ export class SelectorController {
693
803
  this.showCustomModelPresetWizard(selection.profile);
694
804
  return;
695
805
  }
806
+ if (selection.kind === "renameProfile") {
807
+ await this.#renameCustomModelPreset(selection.profileName, modelSelector);
808
+ return;
809
+ }
810
+ if (selection.kind === "deleteProfile") {
811
+ await this.#deleteCustomModelPreset(selection.profileName, modelSelector);
812
+ return;
813
+ }
696
814
  if (selection.kind === "profile") {
815
+ const profileLabel = formatModelProfileDisplayLabel(
816
+ this.ctx.session.modelRegistry.getModelProfile(selection.profileName) ?? {
817
+ name: selection.profileName,
818
+ },
819
+ );
697
820
  await activateModelProfile(
698
821
  {
699
822
  session: this.ctx.session,
@@ -707,8 +830,8 @@ export class SelectorController {
707
830
  this.ctx.updateEditorBorderColor();
708
831
  this.ctx.showStatus(
709
832
  selection.setDefault
710
- ? `Default model profile: ${selection.profileName}`
711
- : `Model profile: ${selection.profileName}`,
833
+ ? `Default model profile: ${profileLabel}`
834
+ : `Model profile: ${profileLabel}`,
712
835
  );
713
836
  done();
714
837
  this.ctx.ui.requestRender();
@@ -1338,7 +1461,7 @@ export class SelectorController {
1338
1461
  }
1339
1462
  }
1340
1463
 
1341
- let externalCredentialCandidates: ReturnType<typeof filterAutoImportOAuthCredentials> = [];
1464
+ let externalCredentialCandidates: ImportableCredential[] = [];
1342
1465
  if (
1343
1466
  mode === "login" &&
1344
1467
  providerId === undefined &&
@@ -2684,8 +2684,13 @@ export class InteractiveMode implements InteractiveModeContext {
2684
2684
  this.#extensionUiController.hideHookSelector();
2685
2685
  }
2686
2686
 
2687
- showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
2688
- return this.#extensionUiController.showHookInput(title, placeholder);
2687
+ showHookInput(
2688
+ title: string,
2689
+ placeholder?: string,
2690
+ dialogOptions?: ExtensionUIDialogOptions,
2691
+ inputOptions?: { readonly initialValue?: string },
2692
+ ): Promise<string | undefined> {
2693
+ return this.#extensionUiController.showHookInput(title, placeholder, dialogOptions, inputOptions);
2689
2694
  }
2690
2695
 
2691
2696
  hideHookInput(): void {
@@ -11,7 +11,7 @@
11
11
  * the interactive select/editor UI) when an unattended controller + gate broker
12
12
  * are attached is wired with the transport in #321 and exercised by #323.
13
13
  */
14
- import type { RpcJsonSchema } from "../../rpc/rpc-types";
14
+ import type { RpcJsonSchema, RpcWorkflowGateKind, RpcWorkflowStage } from "../../rpc/rpc-types";
15
15
  import type { OpenGateInput } from "./workflow-gate-broker";
16
16
 
17
17
  /** "Other (type your own)" sentinel, mirroring the interactive ask tool. */
@@ -26,6 +26,11 @@ export interface AskGateDeepInterviewState {
26
26
  ambiguity: number;
27
27
  }
28
28
 
29
+ export interface AskGateWorkflowGateMeta {
30
+ stage: RpcWorkflowStage;
31
+ kind: RpcWorkflowGateKind;
32
+ }
33
+
29
34
  export interface AskGateQuestion {
30
35
  id: string;
31
36
  question: string;
@@ -37,6 +42,8 @@ export interface AskGateQuestion {
37
42
  * `stage_state`; when absent, the question text is regex-parsed as a fallback.
38
43
  */
39
44
  deepInterview?: AskGateDeepInterviewState;
45
+ /** Override the emitted workflow gate address for non-deep-interview ask prompts. */
46
+ workflowGate?: AskGateWorkflowGateMeta;
40
47
  }
41
48
 
42
49
  export interface AskGateResult {
@@ -46,6 +53,7 @@ export interface AskGateResult {
46
53
  multi: boolean;
47
54
  selectedOptions: string[];
48
55
  customInput?: string;
56
+ clarificationQuestion?: string;
49
57
  }
50
58
 
51
59
  /**
@@ -54,12 +62,15 @@ export interface AskGateResult {
54
62
  * `selected` are picked option labels; free text is conveyed by `other: true`
55
63
  * plus `custom`, encoded separately from `selected` so a real option whose label
56
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.
57
69
  */
58
- export interface DeepInterviewGateAnswer {
59
- selected: string[];
60
- other?: boolean;
61
- custom?: string;
62
- }
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 };
63
74
 
64
75
  export class DeepInterviewGateError extends Error {
65
76
  constructor(
@@ -68,6 +79,7 @@ export class DeepInterviewGateError extends Error {
68
79
  | "unknown_option"
69
80
  | "multi_not_allowed"
70
81
  | "missing_custom"
82
+ | "missing_clarification"
71
83
  | "empty_selection"
72
84
  | "duplicate_selection",
73
85
  message: string,
@@ -120,13 +132,22 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
120
132
  selected: selectedBase,
121
133
  other: { type: "boolean", description: "set true to provide a free-text answer in `custom`" },
122
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
+ },
123
145
  },
124
- required: ["selected"],
125
146
  additionalProperties: false,
126
147
  anyOf: [
127
148
  {
128
149
  type: "object",
129
- properties: { selected: selectedOnly, other: { const: false } },
150
+ properties: { selected: selectedOnly, other: { const: false }, action: { const: "answer" } },
130
151
  required: ["selected"],
131
152
  additionalProperties: false,
132
153
  },
@@ -136,10 +157,20 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
136
157
  selected: selectedWithOther,
137
158
  other: { const: true },
138
159
  custom: { type: "string", minLength: 1 },
160
+ action: { const: "answer" },
139
161
  },
140
162
  required: ["selected", "other", "custom"],
141
163
  additionalProperties: false,
142
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
+ },
143
174
  ],
144
175
  };
145
176
  }
@@ -157,13 +188,13 @@ function structuredDeepInterviewState(meta: AskGateDeepInterviewState): Record<s
157
188
  return state;
158
189
  }
159
190
 
160
- /** Build the `workflow_gate` open-input for one deep-interview question. */
191
+ /** Build the `workflow_gate` open-input for one ask question. */
161
192
  export function questionToGate(question: AskGateQuestion): OpenGateInput {
162
193
  const labels = question.options.map(o => o.label);
163
194
  const schema = questionAnswerSchema(question, labels);
164
195
  return {
165
- stage: "deep-interview",
166
- kind: "question",
196
+ stage: question.workflowGate?.stage ?? "deep-interview",
197
+ kind: question.workflowGate?.kind ?? "question",
167
198
  schema,
168
199
  options: question.options.map((o, i) => ({
169
200
  value: o.label,
@@ -178,6 +209,7 @@ export function questionToGate(question: AskGateQuestion): OpenGateInput {
178
209
  multi: question.multi ?? false,
179
210
  options: labels,
180
211
  other_option: GATE_OTHER_OPTION,
212
+ clarification_action: "clarify",
181
213
  ...(question.deepInterview
182
214
  ? structuredDeepInterviewState(question.deepInterview)
183
215
  : deepInterviewQuestionState(question.question)),
@@ -188,8 +220,14 @@ export function questionToGate(question: AskGateQuestion): OpenGateInput {
188
220
 
189
221
  function isAnswer(value: unknown): value is DeepInterviewGateAnswer {
190
222
  if (typeof value !== "object" || value === null) return false;
191
- const v = value as DeepInterviewGateAnswer;
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
+ }
192
229
  return (
230
+ (v.action === undefined || v.action === "answer") &&
193
231
  Array.isArray(v.selected) &&
194
232
  v.selected.every(s => typeof s === "string") &&
195
233
  (v.other === undefined || typeof v.other === "boolean") &&
@@ -207,11 +245,24 @@ export function gateAnswerToResult(question: AskGateQuestion, answer: unknown):
207
245
  if (!isAnswer(answer)) {
208
246
  throw new DeepInterviewGateError(
209
247
  "invalid_answer_shape",
210
- "answer must be { selected: string[]; other?: boolean; custom?: string }",
248
+ 'answer must be an answer ({ selected: string[]; other?: boolean; custom?: string }) or clarification ({ action: "clarify"; question: string })',
211
249
  );
212
250
  }
213
251
  const labels = question.options.map(o => o.label);
214
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
+ }
215
266
  const valid = new Set(labels);
216
267
  for (const sel of answer.selected) {
217
268
  if (!valid.has(sel)) throw new DeepInterviewGateError("unknown_option", `unknown option: ${sel}`);
@@ -290,7 +290,12 @@ export interface InteractiveModeContext {
290
290
  dialogOptions?: ExtensionUIDialogOptions,
291
291
  ): Promise<string | undefined>;
292
292
  hideHookSelector(): void;
293
- showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
293
+ showHookInput(
294
+ title: string,
295
+ placeholder?: string,
296
+ dialogOptions?: ExtensionUIDialogOptions,
297
+ inputOptions?: { readonly initialValue?: string },
298
+ ): Promise<string | undefined>;
294
299
  hideHookInput(): void;
295
300
  showHookEditor(
296
301
  title: string,
@@ -0,0 +1,22 @@
1
+ import * as path from "node:path";
2
+
3
+ export interface DaemonPaths {
4
+ dir: string;
5
+ lock: string;
6
+ state: string;
7
+ roots: string;
8
+ steal: string;
9
+ aliases: string;
10
+ }
11
+
12
+ export function daemonPaths(agentDir: string): DaemonPaths {
13
+ const dir = path.join(agentDir, "notifications");
14
+ return {
15
+ dir,
16
+ lock: path.join(dir, "telegram-daemon.lock"),
17
+ state: path.join(dir, "telegram-daemon.state.json"),
18
+ roots: path.join(dir, "telegram-daemon.roots.json"),
19
+ steal: path.join(dir, "telegram-daemon.steal"),
20
+ aliases: path.join(dir, "telegram-callback-aliases.json"),
21
+ };
22
+ }
@@ -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
- if (!diff && !tokenUsage && !modelId) return;
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
- readFirstLine?: (file: string) => string | undefined;
42
+ readInitialLines?: (file: string, maxLines: number) => string[];
39
43
  }
40
44
 
41
- function defaultReadFirstLine(file: string): string | undefined {
45
+ function defaultReadInitialLines(file: string, maxLines: number): string[] {
42
46
  try {
43
- const buf = fs.readFileSync(file, "utf8");
44
- const nl = buf.indexOf("\n");
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 undefined;
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 readFirstLine = deps.readFirstLine ?? defaultReadFirstLine;
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 meta = headerMeta(readFirstLine(file));
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
  }
@@ -3,12 +3,19 @@ import * as path from "node:path";
3
3
  import { YAML } from "bun";
4
4
  import type { Settings } from "../config/settings";
5
5
  import { getNotificationConfig, isGloballyConfigured } from "./config";
6
- import { daemonPaths, TelegramNotificationDaemon } from "./telegram-daemon";
7
- import { clearTelegramControlRequest, readTelegramControlRequest } from "./telegram-daemon-control";
6
+ import { daemonPaths } from "./daemon-paths";
7
+ import type { TelegramDaemonOptions } from "./telegram-daemon";
8
+
9
+ type TelegramDaemonRunner = {
10
+ run(): Promise<void>;
11
+ requestStop(reason?: "reload" | "signal" | "stop"): void;
12
+ };
13
+
14
+ type TelegramDaemonConstructor = new (opts: TelegramDaemonOptions) => TelegramDaemonRunner;
8
15
 
9
16
  export interface RunDaemonInternalDeps {
10
17
  SettingsImpl?: { init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir">> };
11
- DaemonImpl?: typeof TelegramNotificationDaemon;
18
+ DaemonImpl?: TelegramDaemonConstructor;
12
19
  processPid?: number;
13
20
  pidAlive?: (pid: number) => boolean;
14
21
  }
@@ -140,7 +147,9 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
140
147
  const settings = await resolveDaemonSettings(resolvedAgentDir, deps);
141
148
  const cfg = getNotificationConfig(settings as Settings);
142
149
  if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return;
143
- const Daemon = deps.DaemonImpl ?? TelegramNotificationDaemon;
150
+ const { clearTelegramControlRequest, readTelegramControlRequest } = await import("./telegram-daemon-control");
151
+ const Daemon: TelegramDaemonConstructor =
152
+ deps.DaemonImpl ?? (await import("./telegram-daemon")).TelegramNotificationDaemon;
144
153
  const daemon = new Daemon({
145
154
  settings: settings as Settings,
146
155
  ownerId,