@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +2 -0
  3. package/dist/types/config/settings-schema.d.ts +10 -1
  4. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +7 -0
  6. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -0
  7. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -4
  8. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  9. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +20 -6
  10. package/dist/types/notifications/html-format.d.ts +2 -0
  11. package/dist/types/notifications/recent-activity.d.ts +5 -1
  12. package/dist/types/notifications/telegram-daemon-control.d.ts +8 -0
  13. package/dist/types/notifications/telegram-reference.d.ts +4 -0
  14. package/dist/types/notifications/threaded-render.d.ts +1 -0
  15. package/dist/types/tools/ask.d.ts +2 -0
  16. package/dist/types/utils/cmux-workspace.d.ts +23 -0
  17. package/dist/types/web/search/index.d.ts +1 -1
  18. package/dist/types/web/search/provider.d.ts +5 -0
  19. package/dist/types/web/search/render.d.ts +1 -0
  20. package/package.json +7 -7
  21. package/src/cli/web-search-cli.ts +3 -2
  22. package/src/config/settings-schema.ts +11 -1
  23. package/src/coordinator-mcp/server.ts +1 -1
  24. package/src/defaults/gjc/skills/deep-interview/SKILL.md +26 -2
  25. package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -112
  26. package/src/extensibility/extensions/types.ts +11 -0
  27. package/src/gjc-runtime/launch-tmux.ts +63 -2
  28. package/src/gjc-runtime/ultragoal-guard.ts +139 -19
  29. package/src/gjc-runtime/ultragoal-runtime.ts +16 -124
  30. package/src/gjc-runtime/workflow-manifest.generated.json +0 -8
  31. package/src/gjc-runtime/workflow-manifest.ts +0 -1
  32. package/src/hooks/skill-state.ts +33 -73
  33. package/src/internal-urls/docs-index.generated.ts +2 -1
  34. package/src/modes/components/hook-selector.ts +21 -3
  35. package/src/modes/components/settings-selector.ts +47 -3
  36. package/src/modes/components/status-line/presets.ts +11 -0
  37. package/src/modes/controllers/extension-ui-controller.ts +18 -2
  38. package/src/modes/shared/agent-wire/deep-interview-gate.ts +53 -9
  39. package/src/notifications/html-format.ts +75 -1
  40. package/src/notifications/index.ts +12 -1
  41. package/src/notifications/recent-activity.ts +32 -8
  42. package/src/notifications/telegram-daemon-control.ts +9 -0
  43. package/src/notifications/telegram-daemon.ts +67 -24
  44. package/src/notifications/telegram-reference.ts +36 -14
  45. package/src/notifications/threaded-render.ts +14 -6
  46. package/src/prompts/system/system-prompt.md +5 -1
  47. package/src/sdk.ts +3 -5
  48. package/src/tools/ask.ts +136 -16
  49. package/src/utils/cmux-workspace.ts +106 -0
  50. package/src/utils/title-generator.ts +2 -0
  51. package/src/web/search/index.ts +22 -10
  52. package/src/web/search/provider.ts +15 -0
  53. 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
- this.#customInput?.onSubmit(editor.getExpandedText());
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 = dialogOptions?.customInput ? HOOK_SELECTOR_INLINE_INPUT_ROWS : 0;
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: dialogOptions?.customInput ? this.ctx.editor.getAutocompleteProvider() : undefined,
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 interface DeepInterviewGateAnswer {
66
- selected: string[];
67
- other?: boolean;
68
- custom?: string;
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 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
+ }
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
- "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 })',
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
- 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
  }
@@ -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,