@gajae-code/coding-agent 0.9.6 → 0.10.0

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 (193) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/types/cli/classify-install-target.d.ts +0 -10
  3. package/dist/types/cli/daemon-cli.d.ts +2 -0
  4. package/dist/types/cli/initial-message.d.ts +1 -1
  5. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  6. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  7. package/dist/types/cli/notify-cli.d.ts +3 -1
  8. package/dist/types/cli/session-picker.d.ts +4 -3
  9. package/dist/types/cli/update-cli.d.ts +22 -3
  10. package/dist/types/cli.d.ts +4 -0
  11. package/dist/types/commands/daemon.d.ts +6 -2
  12. package/dist/types/commands/launch.d.ts +1 -0
  13. package/dist/types/commands/notify.d.ts +6 -0
  14. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  15. package/dist/types/commit/analysis/summary.d.ts +1 -1
  16. package/dist/types/commit/changelog/generate.d.ts +1 -1
  17. package/dist/types/commit/changelog/index.d.ts +2 -2
  18. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  19. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  20. package/dist/types/config/keybindings.d.ts +5 -0
  21. package/dist/types/config/model-profile-activation.d.ts +1 -0
  22. package/dist/types/config/settings-schema.d.ts +47 -7
  23. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  24. package/dist/types/coordinator-mcp/server.d.ts +67 -6
  25. package/dist/types/daemon/control-types.d.ts +10 -0
  26. package/dist/types/daemon/operator-contract.d.ts +47 -0
  27. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  28. package/dist/types/discovery/helpers.d.ts +1 -1
  29. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  30. package/dist/types/gjc-runtime/launch-tmux.d.ts +26 -7
  31. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +34 -3
  32. package/dist/types/gjc-runtime/tmux-common.d.ts +6 -0
  33. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +309 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +49 -17
  36. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  37. package/dist/types/main.d.ts +66 -2
  38. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  39. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  40. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  42. package/dist/types/modes/components/gajae-pet-widget.d.ts +59 -0
  43. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  44. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  45. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  46. package/dist/types/modes/components/session-selector.d.ts +22 -9
  47. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  48. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  49. package/dist/types/modes/components/user-message.d.ts +2 -1
  50. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  51. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  52. package/dist/types/modes/interactive-mode.d.ts +24 -2
  53. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  54. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  55. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  56. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  57. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  58. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  59. package/dist/types/modes/types.d.ts +28 -2
  60. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  61. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  62. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  63. package/dist/types/notifications/config.d.ts +34 -0
  64. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  65. package/dist/types/notifications/index.d.ts +27 -0
  66. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  67. package/dist/types/notifications/notification-service.d.ts +144 -0
  68. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  69. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  70. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  71. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  72. package/dist/types/notifications/topic-registry.d.ts +24 -4
  73. package/dist/types/registry/agent-registry.d.ts +2 -0
  74. package/dist/types/rlm/index.d.ts +1 -1
  75. package/dist/types/sdk.d.ts +2 -0
  76. package/dist/types/secrets/obfuscator.d.ts +1 -1
  77. package/dist/types/session/agent-session.d.ts +26 -2
  78. package/dist/types/session/cache-economics.d.ts +79 -0
  79. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  80. package/dist/types/session/session-manager.d.ts +124 -4
  81. package/dist/types/session/session-storage.d.ts +155 -13
  82. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  83. package/dist/types/task/executor.d.ts +3 -0
  84. package/dist/types/task/receipt.d.ts +3 -1
  85. package/dist/types/task/types.d.ts +8 -0
  86. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  87. package/dist/types/tools/ask.d.ts +25 -1
  88. package/dist/types/tools/bash.d.ts +1 -1
  89. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  90. package/dist/types/tools/index.d.ts +58 -6
  91. package/dist/types/tools/telegram-send.d.ts +7 -0
  92. package/dist/types/utils/external-editor.d.ts +2 -0
  93. package/package.json +9 -9
  94. package/scripts/g004-tmux-smoke.ts +196 -36
  95. package/src/cli/daemon-cli.ts +19 -29
  96. package/src/cli/malloc-env-guard.test.ts +104 -0
  97. package/src/cli/malloc-env-guard.ts +149 -0
  98. package/src/cli/notify-cli.ts +78 -14
  99. package/src/cli/session-picker.ts +23 -40
  100. package/src/cli/update-cli.ts +56 -37
  101. package/src/cli.ts +50 -10
  102. package/src/commands/daemon.ts +19 -5
  103. package/src/commands/harness.ts +515 -47
  104. package/src/commands/launch.ts +11 -3
  105. package/src/commands/notify.ts +5 -1
  106. package/src/commands/session.ts +6 -1
  107. package/src/config/keybindings.ts +5 -0
  108. package/src/config/model-profile-activation.ts +1 -1
  109. package/src/config/model-profiles.ts +14 -14
  110. package/src/config/settings-schema.ts +41 -1
  111. package/src/coordinator-mcp/model-preset.ts +100 -0
  112. package/src/coordinator-mcp/server.ts +2653 -425
  113. package/src/daemon/control-types.ts +11 -0
  114. package/src/daemon/operator-contract.ts +121 -0
  115. package/src/deep-interview/render-middleware.ts +67 -2
  116. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  117. package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  118. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  119. package/src/gjc-runtime/launch-tmux.ts +702 -135
  120. package/src/gjc-runtime/session-state-sidecar.ts +756 -135
  121. package/src/gjc-runtime/team-runtime.ts +6 -0
  122. package/src/gjc-runtime/tmux-common.ts +26 -7
  123. package/src/gjc-runtime/tmux-gc.ts +52 -9
  124. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +185 -0
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +2060 -0
  126. package/src/gjc-runtime/tmux-sessions.ts +871 -68
  127. package/src/internal-urls/docs-index.generated.ts +9 -8
  128. package/src/internal-urls/local-protocol.ts +29 -10
  129. package/src/main.ts +298 -75
  130. package/src/modes/acp/acp-agent.ts +728 -98
  131. package/src/modes/acp/acp-mode.ts +29 -2
  132. package/src/modes/components/assistant-message.ts +32 -3
  133. package/src/modes/components/bash-execution.ts +43 -13
  134. package/src/modes/components/custom-editor.ts +5 -0
  135. package/src/modes/components/gajae-pet-widget.ts +360 -0
  136. package/src/modes/components/hook-selector.ts +12 -0
  137. package/src/modes/components/irc-sidebar.ts +206 -0
  138. package/src/modes/components/pet-selector.ts +55 -0
  139. package/src/modes/components/session-selector.ts +222 -221
  140. package/src/modes/components/settings-selector.ts +10 -0
  141. package/src/modes/components/tool-execution.ts +47 -6
  142. package/src/modes/components/user-message.ts +26 -9
  143. package/src/modes/controllers/command-controller.ts +28 -2
  144. package/src/modes/controllers/event-controller.ts +159 -36
  145. package/src/modes/controllers/extension-ui-controller.ts +36 -17
  146. package/src/modes/controllers/input-controller.ts +8 -2
  147. package/src/modes/controllers/selector-controller.ts +62 -15
  148. package/src/modes/interactive-mode.ts +183 -7
  149. package/src/modes/irc-observation-ledger.ts +127 -0
  150. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  151. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  152. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  153. package/src/modes/types.ts +34 -2
  154. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  155. package/src/modes/utils/irc-message.ts +138 -0
  156. package/src/modes/utils/ui-helpers.ts +216 -50
  157. package/src/notifications/config.ts +44 -0
  158. package/src/notifications/daemon-paths.ts +8 -0
  159. package/src/notifications/index.ts +535 -75
  160. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  161. package/src/notifications/notification-service.ts +710 -0
  162. package/src/notifications/rate-limit-pool.ts +46 -12
  163. package/src/notifications/telegram-daemon-cli.ts +2 -0
  164. package/src/notifications/telegram-daemon-control.ts +27 -2
  165. package/src/notifications/telegram-daemon.ts +635 -94
  166. package/src/notifications/telegram-reference.ts +73 -13
  167. package/src/notifications/topic-registry.ts +86 -9
  168. package/src/prompts/system/irc-peer-roster.md +1 -0
  169. package/src/prompts/system/subagent-system-prompt.md +1 -0
  170. package/src/prompts/system/system-prompt.md +1 -0
  171. package/src/prompts/tools/irc.md +1 -1
  172. package/src/registry/agent-registry.ts +3 -0
  173. package/src/sdk.ts +52 -1
  174. package/src/secrets/obfuscator.ts +4 -2
  175. package/src/session/agent-session.ts +396 -145
  176. package/src/session/cache-economics.ts +275 -0
  177. package/src/session/provider-safety-stop.ts +9 -0
  178. package/src/session/session-dump-format.ts +6 -0
  179. package/src/session/session-manager.ts +809 -39
  180. package/src/session/session-storage.ts +582 -38
  181. package/src/slash-commands/builtin-registry.ts +143 -9
  182. package/src/task/executor.ts +12 -3
  183. package/src/task/index.ts +8 -0
  184. package/src/task/receipt.ts +7 -2
  185. package/src/task/types.ts +26 -0
  186. package/src/tools/ask-answer-registry.ts +25 -0
  187. package/src/tools/ask.ts +355 -33
  188. package/src/tools/browser/tab-supervisor.ts +63 -6
  189. package/src/tools/browser.ts +8 -3
  190. package/src/tools/index.ts +81 -6
  191. package/src/tools/sqlite-reader.ts +8 -4
  192. package/src/tui/output-block.ts +18 -6
  193. package/src/utils/external-editor.ts +6 -1
@@ -0,0 +1,138 @@
1
+ import * as crypto from "node:crypto";
2
+ import type { AgentMessage } from "@gajae-code/agent-core";
3
+ import { getSegmenter } from "@gajae-code/tui";
4
+ import { sanitizeText } from "@gajae-code/utils";
5
+ import { associateSessionMessageObservationId, getSessionMessageObservationId } from "../../session/session-manager";
6
+
7
+ const graphemeSegmenter = getSegmenter();
8
+ const IRC_IDENTITY_SOURCE_MAX_UTF8_BYTES = 4 * 1_024;
9
+ const IRC_IDENTITY_DISPLAY_MAX_UTF8_BYTES = 256;
10
+ export type IrcMessageKind = "incoming" | "autoreply" | "relay";
11
+
12
+ export type ParsedIrcMessage = {
13
+ observationId: string;
14
+ from: string;
15
+ to: string;
16
+ text: string;
17
+ timestamp: number;
18
+ kind: IrcMessageKind;
19
+ };
20
+
21
+ export interface IrcMessageBlock {
22
+ readonly sender: string;
23
+ readonly recipient: string;
24
+ readonly kind: "incoming" | "autoreply" | "outgoing";
25
+ readonly time: string;
26
+ readonly bodyLines: readonly string[];
27
+ }
28
+
29
+ export function projectIrcText(
30
+ text: string,
31
+ maxUtf8Bytes: number,
32
+ ): { text: string; truncated: boolean; utf8Bytes: number } {
33
+ let end = 0;
34
+ let utf8Bytes = 0;
35
+ for (const part of graphemeSegmenter.segment(text)) {
36
+ const segmentBytes = Buffer.byteLength(part.segment, "utf8");
37
+ if (utf8Bytes + segmentBytes > maxUtf8Bytes) break;
38
+ utf8Bytes += segmentBytes;
39
+ end = part.index + part.segment.length;
40
+ }
41
+ return { text: text.slice(0, end), truncated: end < text.length, utf8Bytes };
42
+ }
43
+
44
+ /** Normalizes and bounds untrusted IRC identity fields without splitting visible graphemes. */
45
+ export function normalizeIrcIdentity(identity: string): string {
46
+ const source = projectIrcText(identity, IRC_IDENTITY_SOURCE_MAX_UTF8_BYTES);
47
+ const sanitized = sanitizeText(source.text.replace(/[\r\n\t\u2028\u2029]+/g, " ")).replace(
48
+ /[\u061C\u200E-\u200F\u202A-\u202E\u2066-\u2069]/g,
49
+ "",
50
+ );
51
+ const display = projectIrcText(sanitized, IRC_IDENTITY_DISPLAY_MAX_UTF8_BYTES);
52
+ return source.truncated || display.truncated ? `${display.text}…` : display.text;
53
+ }
54
+
55
+ /** Formats IRC observations into display-neutral semantic blocks for both IRC surfaces. */
56
+ export function formatIrcMessageBlock(message: ParsedIrcMessage & { timestamp: number }): IrcMessageBlock {
57
+ const date = new Date(message.timestamp);
58
+ // IRC timestamps are human-facing, so use local time rather than UTC serialization.
59
+ const time = Number.isFinite(date.getTime())
60
+ ? `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`
61
+ : "--:--";
62
+ const bodyLines = message.text === "" ? [] : message.text.replaceAll("\t", " ").split("\n");
63
+ return {
64
+ sender: normalizeIrcIdentity(message.from),
65
+ recipient: normalizeIrcIdentity(message.to),
66
+ kind: message.kind === "relay" ? "outgoing" : message.kind,
67
+ time,
68
+ bodyLines,
69
+ };
70
+ }
71
+
72
+ type IrcCustomMessage = Extract<AgentMessage, { role: "custom" }>;
73
+
74
+ export function isIrcCustomType(customType: string | undefined): customType is `irc:${IrcMessageKind}` {
75
+ return customType === "irc:incoming" || customType === "irc:autoreply" || customType === "irc:relay";
76
+ }
77
+
78
+ function stringDetail(details: unknown, key: string): string {
79
+ if (!details || typeof details !== "object") return "";
80
+ const value = (details as Record<string, unknown>)[key];
81
+ return typeof value === "string" ? value : "";
82
+ }
83
+
84
+ function legacyObservationId(fields: readonly string[]): string {
85
+ const hash = crypto.createHash("sha256");
86
+ hash.update("gjc:irc:legacy-observation:v1\0");
87
+ for (const field of fields) {
88
+ hash.update(String(Buffer.byteLength(field, "utf8")));
89
+ hash.update(":");
90
+ hash.update(field);
91
+ }
92
+ return `legacy:sha256:${hash.digest("hex")}`;
93
+ }
94
+
95
+ /**
96
+ * Normalizes every supported IRC custom message into its UI observation shape.
97
+ * Legacy records have no UUID, so their immutable payload is used as a stable key.
98
+ */
99
+ export function parseIrcMessage(message: IrcCustomMessage): ParsedIrcMessage | undefined {
100
+ if (!isIrcCustomType(message.customType)) return undefined;
101
+
102
+ const kind = message.customType.slice(4) as IrcMessageKind;
103
+ const sourceTimestamp =
104
+ typeof message.timestamp === "number" && Number.isFinite(message.timestamp) ? message.timestamp : undefined;
105
+ const timestamp = sourceTimestamp ?? Date.now();
106
+ const rawFrom = kind === "autoreply" ? "you" : stringDetail(message.details, "from") || "?";
107
+ const rawTo = kind === "incoming" ? "you" : stringDetail(message.details, "to") || "?";
108
+ const rawText =
109
+ kind === "incoming"
110
+ ? stringDetail(message.details, "message")
111
+ : kind === "autoreply"
112
+ ? stringDetail(message.details, "reply")
113
+ : stringDetail(message.details, "body");
114
+ const from = normalizeIrcIdentity(rawFrom);
115
+ const to = normalizeIrcIdentity(rawTo);
116
+ const text = sanitizeText(rawText);
117
+ const explicitObservationId = stringDetail(message.details, "observationId");
118
+ const observationId = associateSessionMessageObservationId(
119
+ message,
120
+ explicitObservationId ||
121
+ getSessionMessageObservationId(message) ||
122
+ legacyObservationId([
123
+ kind,
124
+ sourceTimestamp === undefined ? "null" : String(sourceTimestamp),
125
+ rawFrom,
126
+ rawTo,
127
+ rawText,
128
+ ]),
129
+ );
130
+ return {
131
+ observationId,
132
+ from,
133
+ to,
134
+ text,
135
+ timestamp,
136
+ kind,
137
+ };
138
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AgentMessage } from "@gajae-code/agent-core";
2
2
  import type { AssistantMessage, ImageContent, Message } from "@gajae-code/ai";
3
- import { type Component, Spacer, Text, TruncatedText } from "@gajae-code/tui";
3
+ import { type Component, Spacer, Text, TruncatedText, type TUI, truncateToWidth } from "@gajae-code/tui";
4
4
  import { settings } from "../../config/settings";
5
5
  import { resolveSubskillActivationForSkillInvocation } from "../../extensibility/gjc-plugins";
6
6
  import { buildSkillPromptMessage, parseSkillInvocations } from "../../extensibility/skills";
@@ -20,17 +20,69 @@ import { SkillMessageComponent } from "../../modes/components/skill-message";
20
20
  import { ToolExecutionComponent } from "../../modes/components/tool-execution";
21
21
  import { UserMessageComponent } from "../../modes/components/user-message";
22
22
  import { theme } from "../../modes/theme/theme";
23
- import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types";
23
+ import type {
24
+ CompactionQueuedMessage,
25
+ InteractiveModeContext,
26
+ IrcArrivalSnapshot,
27
+ TranscriptRebuildPolicy,
28
+ } from "../../modes/types";
24
29
  import {
25
30
  type CustomMessage,
26
31
  isSilentAbort,
27
32
  SKILL_PROMPT_MESSAGE_TYPE,
28
33
  type SkillPromptDetails,
29
34
  } from "../../session/messages";
30
- import type { SessionContext } from "../../session/session-manager";
35
+ import {
36
+ associateSessionMessageViewportAnchorId,
37
+ getSessionMessageEntryId,
38
+ getSessionMessageViewportAnchorId,
39
+ type SessionContext,
40
+ } from "../../session/session-manager";
31
41
  import { formatBytes, formatDuration } from "../../tools/render-utils";
32
42
  import { buildAbortDisplayMessage } from "./abort-message";
43
+ import {
44
+ formatIrcMessageBlock,
45
+ isIrcCustomType,
46
+ type ParsedIrcMessage,
47
+ parseIrcMessage,
48
+ projectIrcText,
49
+ } from "./irc-message";
50
+
51
+ export type { TranscriptRebuildPolicy } from "../../modes/types";
52
+
53
+ const IRC_INLINE_MAX_RENDER_ROWS = 2_048;
54
+ const IRC_INLINE_MAX_SOURCE_UTF8_BYTES = 64 * 1_024;
55
+ const IRC_INLINE_ELISION = " … message elided …";
56
+
57
+ class BoundedIrcTextComponent implements Component {
58
+ #text: Text;
59
+ #sourceTruncated: boolean;
60
+
61
+ constructor(text: string, sourceTruncated: boolean) {
62
+ this.#text = new Text(text, 0, 0);
63
+ this.#sourceTruncated = sourceTruncated;
64
+ }
65
+
66
+ render(width: number): string[] {
67
+ const rendered = this.#text.render(width);
68
+ if (!this.#sourceTruncated && rendered.length <= IRC_INLINE_MAX_RENDER_ROWS) return rendered;
69
+ const lines = rendered.slice(0, IRC_INLINE_MAX_RENDER_ROWS);
70
+ const marker = truncateToWidth(theme.fg("dim", IRC_INLINE_ELISION), width);
71
+ if (lines.length === 0) return [marker];
72
+ if (this.#sourceTruncated && rendered.length < IRC_INLINE_MAX_RENDER_ROWS) lines.push(marker);
73
+ else lines[lines.length - 1] = marker;
74
+ return lines;
75
+ }
33
76
 
77
+ invalidate(): void {
78
+ this.#text.invalidate();
79
+ }
80
+ }
81
+
82
+ export function prepareTranscriptRebuild(ui: TUI, policy: TranscriptRebuildPolicy): void {
83
+ if (policy === "replace-identity") ui.resetViewportAnchorIntent();
84
+ else ui.prepareViewportAnchorForTranscriptRebuild();
85
+ }
34
86
  type TextBlock = { type: "text"; text: string };
35
87
  interface RenderInitialMessagesOptions {
36
88
  preserveExistingChat?: boolean;
@@ -118,8 +170,18 @@ function getChatChildTime(component: Component): number {
118
170
  return chatChildAddedAt.get(component) ?? Date.now();
119
171
  }
120
172
 
173
+ function stableSemanticIdPart(value: string): string {
174
+ let hash = 0xcbf29ce484222325n;
175
+ for (const char of value) {
176
+ hash ^= BigInt(char.codePointAt(0)!);
177
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
178
+ }
179
+ return hash.toString(36);
180
+ }
181
+
121
182
  export function addChatChild(ctx: InteractiveModeContext, component: Component): void {
122
183
  ctx.chatContainer.addChild(component);
184
+
123
185
  chatChildAddedAt.set(component, Date.now());
124
186
  trimChatChildren(ctx);
125
187
  }
@@ -186,8 +248,114 @@ export function trimChatChildren(ctx: InteractiveModeContext): void {
186
248
  }
187
249
 
188
250
  export class UiHelpers {
251
+ #renderedIrcInlineComponents = new Map<string, readonly Component[]>();
252
+ #viewportAnchorOccurrences = new WeakMap<object, { base: string; epoch: number; id: string }>();
253
+ #nextViewportAnchorOccurrence = new Map<string, number>();
254
+ #viewportAnchorOccurrenceEpoch = 0;
255
+ #ircSidebarHintShown = false;
256
+
189
257
  constructor(private ctx: InteractiveModeContext) {}
190
258
 
259
+ #resetViewportAnchorOccurrencePass(): void {
260
+ this.#viewportAnchorOccurrenceEpoch += 1;
261
+ this.#nextViewportAnchorOccurrence.clear();
262
+ }
263
+
264
+ #viewportAnchorOccurrenceId(message: object, base: string): string {
265
+ const existing = this.#viewportAnchorOccurrences.get(message);
266
+ if (existing?.base === base && existing.epoch === this.#viewportAnchorOccurrenceEpoch) return existing.id;
267
+ const occurrence = this.#nextViewportAnchorOccurrence.get(base) ?? 0;
268
+ this.#nextViewportAnchorOccurrence.set(base, occurrence + 1);
269
+ const id = `${base}:occurrence:${occurrence}`;
270
+ this.#viewportAnchorOccurrences.set(message, {
271
+ base,
272
+ epoch: this.#viewportAnchorOccurrenceEpoch,
273
+ id,
274
+ });
275
+ return id;
276
+ }
277
+
278
+ assistantViewportAnchorId(message: AssistantMessage): string {
279
+ const semanticId = getSessionMessageViewportAnchorId(message);
280
+ if (semanticId) return semanticId;
281
+ const entryId = getSessionMessageEntryId(message);
282
+ if (entryId) return `assistant:entry:${entryId}`;
283
+ const base = `assistant:${message.api}:${message.provider}:${message.model}:${message.timestamp}`;
284
+ const id = this.#viewportAnchorOccurrenceId(message, base);
285
+ associateSessionMessageViewportAnchorId(message, id);
286
+ return id;
287
+ }
288
+
289
+ #userViewportAnchorId(message: Extract<Message, { role: "user" }>): string {
290
+ const semanticId = getSessionMessageViewportAnchorId(message);
291
+ if (semanticId) return semanticId;
292
+ const entryId = getSessionMessageEntryId(message);
293
+ if (entryId) return `user:entry:${entryId}`;
294
+ const base = `user:local:${message.timestamp}:${stableSemanticIdPart(JSON.stringify(message.content))}`;
295
+ const id = this.#viewportAnchorOccurrenceId(message, base);
296
+ associateSessionMessageViewportAnchorId(message, id);
297
+ return id;
298
+ }
299
+
300
+ getRenderedIrcInlineComponents(): Map<string, readonly Component[]> {
301
+ return this.#renderedIrcInlineComponents;
302
+ }
303
+
304
+ removeRenderedIrcInlineComponents(observationId: string): readonly Component[] | undefined {
305
+ const components = this.#renderedIrcInlineComponents.get(observationId);
306
+ this.#renderedIrcInlineComponents.delete(observationId);
307
+ return components;
308
+ }
309
+
310
+ resetRenderedIrcInlineComponents(): readonly (readonly Component[])[] {
311
+ const components = [...this.#renderedIrcInlineComponents.values()];
312
+ this.#renderedIrcInlineComponents.clear();
313
+ return components;
314
+ }
315
+
316
+ #addIrcObservationToChat(message: ParsedIrcMessage, sidebarHint?: string): Component[] {
317
+ const bodyProjection = projectIrcText(message.text, IRC_INLINE_MAX_SOURCE_UTF8_BYTES);
318
+ const block = formatIrcMessageBlock({ ...message, text: bodyProjection.text });
319
+ const components: Component[] = [];
320
+ const header = `${theme.fg("accent", `[IRC] ${block.sender} → ${block.recipient} · ${block.time}`)}${sidebarHint ? theme.fg("dim", sidebarHint) : ""}`;
321
+ const headerComponent = new Text(header, 1, 0);
322
+ addChatChild(this.ctx, headerComponent);
323
+ components.push(headerComponent);
324
+ if (block.bodyLines.length > 0 || bodyProjection.truncated) {
325
+ const bodyComponent = new BoundedIrcTextComponent(
326
+ theme.fg("muted", ` ${block.bodyLines.join("\n ")}`),
327
+ bodyProjection.truncated,
328
+ );
329
+ addChatChild(this.ctx, bodyComponent);
330
+ components.push(bodyComponent);
331
+ }
332
+ return components;
333
+ }
334
+
335
+ addLiveIrcObservationToChat(message: ParsedIrcMessage, arrival: IrcArrivalSnapshot): Component[] {
336
+ // Requested-open panels that merely yielded at narrow widths must not
337
+ // advertise the toggle key: pressing it would close the pending request.
338
+ const showSidebarHint =
339
+ !arrival.panelVisible &&
340
+ !arrival.panelRequestedVisible &&
341
+ arrival.sidebarAvailable &&
342
+ Boolean(arrival.resolvedToggleKey) &&
343
+ !this.#ircSidebarHintShown;
344
+ if (showSidebarHint) this.#ircSidebarHintShown = true;
345
+ return this.#addIrcObservationToChat(
346
+ message,
347
+ showSidebarHint ? ` · ${arrival.resolvedToggleKey} opens sidebar` : undefined,
348
+ );
349
+ }
350
+
351
+ addRebuiltIrcObservationToChat(message: ParsedIrcMessage): Component[] {
352
+ return this.#addIrcObservationToChat(message);
353
+ }
354
+
355
+ resetIrcSidebarHint(): void {
356
+ this.#ircSidebarHintShown = false;
357
+ }
358
+
191
359
  /** Extract text content from a user message */
192
360
  getUserMessageText(message: Message): string {
193
361
  if (message.role !== "user") return "";
@@ -304,50 +472,9 @@ export class UiHelpers {
304
472
  addChatChild(this.ctx, component);
305
473
  break;
306
474
  }
307
- if (
308
- message.customType === "irc:incoming" ||
309
- message.customType === "irc:autoreply" ||
310
- message.customType === "irc:relay"
311
- ) {
312
- const details = (
313
- message as CustomMessage<{
314
- from?: string;
315
- to?: string;
316
- message?: string;
317
- reply?: string;
318
- body?: string;
319
- kind?: "message" | "reply";
320
- }>
321
- ).details;
322
- let arrow: string;
323
- let body: string;
324
- if (message.customType === "irc:incoming") {
325
- const peer = details?.from ?? "?";
326
- body = details?.message ?? "";
327
- arrow = `⇦ ${peer}`;
328
- } else if (message.customType === "irc:autoreply") {
329
- const peer = details?.to ?? "?";
330
- body = details?.reply ?? "";
331
- arrow = `⇨ ${peer}`;
332
- } else {
333
- const from = details?.from ?? "?";
334
- const to = details?.to ?? "?";
335
- body = details?.body ?? "";
336
- arrow = `${from} ⇨ ${to}`;
337
- }
338
- const components: Component[] = [];
339
- const header = `${theme.fg("accent", `[IRC] ${arrow}`)}`;
340
- const headerComponent = new Text(header, 1, 0);
341
- addChatChild(this.ctx, headerComponent);
342
- components.push(headerComponent);
343
- if (body) {
344
- for (const line of body.split("\n")) {
345
- const lineComponent = new Text(theme.fg("muted", ` ${line}`), 0, 0);
346
- addChatChild(this.ctx, lineComponent);
347
- components.push(lineComponent);
348
- }
349
- }
350
- return components;
475
+ if (message.role === "custom" && isIrcCustomType(message.customType)) {
476
+ const parsed = parseIrcMessage(message);
477
+ if (parsed) return this.addRebuiltIrcObservationToChat(parsed);
351
478
  }
352
479
  if (message.customType === "subagent:steer" || message.customType === "subagent:steer:relay") {
353
480
  const details = (
@@ -421,7 +548,11 @@ export class UiHelpers {
421
548
  const textContent = this.ctx.getUserMessageText(message);
422
549
  if (textContent) {
423
550
  const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
424
- const userComponent = new UserMessageComponent(textContent, isSynthetic);
551
+ const userComponent = new UserMessageComponent(
552
+ textContent,
553
+ isSynthetic,
554
+ message.role === "user" && !isSynthetic ? this.#userViewportAnchorId(message) : undefined,
555
+ );
425
556
  addChatChild(this.ctx, userComponent);
426
557
  if (options?.populateHistory && message.role === "user" && !isSynthetic) {
427
558
  this.ctx.editor.addToHistory(textContent);
@@ -430,8 +561,11 @@ export class UiHelpers {
430
561
  break;
431
562
  }
432
563
  case "assistant": {
433
- const assistantComponent = new AssistantMessageComponent(message, this.ctx.hideThinkingBlock, () =>
434
- this.ctx.ui.requestRender(),
564
+ const assistantComponent = new AssistantMessageComponent(
565
+ message,
566
+ this.ctx.hideThinkingBlock,
567
+ () => this.ctx.ui.requestRender(),
568
+ this.assistantViewportAnchorId(message),
435
569
  );
436
570
  addChatChild(this.ctx, assistantComponent);
437
571
  break;
@@ -469,12 +603,35 @@ export class UiHelpers {
469
603
  const readToolCallArgs = new Map<string, Record<string, unknown>>();
470
604
  const readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
471
605
  const deferredMessages: AgentMessage[] = [];
606
+ const persistedIrcObservationIds = new Set<string>();
607
+ const now = Date.now();
608
+ this.#resetViewportAnchorOccurrencePass();
609
+ this.#renderedIrcInlineComponents.clear();
472
610
  for (const message of sessionContext.messages) {
473
611
  // Defer compaction summaries so they render at the bottom (visible after scroll)
474
612
  if (message.role === "compactionSummary") {
475
613
  deferredMessages.push(message);
476
614
  continue;
477
615
  }
616
+ if (message.role === "custom" && isIrcCustomType(message.customType)) {
617
+ const parsed = parseIrcMessage(message);
618
+ if (parsed) {
619
+ persistedIrcObservationIds.add(parsed.observationId);
620
+ const record = this.ctx.ircLedger.getRecord(parsed.observationId);
621
+ if (
622
+ record &&
623
+ (record.mode === "persistent" || now < record.expiresAt!) &&
624
+ !this.#renderedIrcInlineComponents.has(record.observationId)
625
+ ) {
626
+ this.#renderedIrcInlineComponents.set(
627
+ record.observationId,
628
+ this.addRebuiltIrcObservationToChat(record),
629
+ );
630
+ }
631
+ }
632
+ continue;
633
+ }
634
+
478
635
  // Assistant messages need special handling for tool calls
479
636
  if (message.role === "assistant") {
480
637
  this.ctx.addMessageToChat(message);
@@ -625,6 +782,15 @@ export class UiHelpers {
625
782
  for (const message of deferredMessages) {
626
783
  this.ctx.addMessageToChat(message, options);
627
784
  }
785
+ for (const record of this.ctx.ircLedger.getInlineProjection(now)) {
786
+ if (
787
+ persistedIrcObservationIds.has(record.observationId) ||
788
+ this.#renderedIrcInlineComponents.has(record.observationId)
789
+ ) {
790
+ continue;
791
+ }
792
+ this.#renderedIrcInlineComponents.set(record.observationId, this.addRebuiltIrcObservationToChat(record));
793
+ }
628
794
 
629
795
  this.ctx.pendingTools.clear();
630
796
  this.ctx.ui.requestRender();
@@ -1,6 +1,17 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import type { Settings } from "../config/settings";
3
3
 
4
+ /**
5
+ * Env marker set by GJC's own programmatic separate-process child spawn sites
6
+ * (team workers, harness RPC owners) and carrying the spawning session id.
7
+ *
8
+ * Presence — not the value — marks a session as GJC-spawned. It is consumed
9
+ * (read once, then deleted from the child's own env) at startup so it is
10
+ * per-spawn rather than dynastic: a grandchild is marked only if its own spawn
11
+ * site marks it, never by inheriting a marked ancestor's environment.
12
+ */
13
+ export const SPAWN_PROVENANCE_ENV = "GJC_SPAWNED_BY_SESSION";
14
+
4
15
  export interface NotificationConfig {
5
16
  enabled: boolean;
6
17
  botToken?: string;
@@ -15,6 +26,12 @@ export interface NotificationConfig {
15
26
  };
16
27
  redact: boolean;
17
28
  verbosity: "lean" | "verbose";
29
+ /**
30
+ * Which sessions may register a notification endpoint. `all` (default)
31
+ * preserves historical behavior; `primary` suppresses GJC-spawned children
32
+ * (those carrying {@link SPAWN_PROVENANCE_ENV}) unless they explicitly opt in.
33
+ */
34
+ sessionScope: "all" | "primary";
18
35
  idleTimeoutMs: number;
19
36
  rich: {
20
37
  enabled: boolean;
@@ -22,6 +39,15 @@ export interface NotificationConfig {
22
39
  richDraft: {
23
40
  enabled: boolean;
24
41
  };
42
+ topics: {
43
+ /**
44
+ * Optional Telegram forum-topic name template with `{repo}`, `{branch}`,
45
+ * and `{title}` placeholders. Unset preserves the built-in
46
+ * `{repo}/{branch} - {title}` composition (with its title/repo/branch
47
+ * fallbacks).
48
+ */
49
+ nameTemplate?: string;
50
+ };
25
51
  }
26
52
 
27
53
  /** Read typed config from Settings. */
@@ -40,6 +66,7 @@ export function getNotificationConfig(settings: Settings): NotificationConfig {
40
66
  },
41
67
  redact: settings.get("notifications.redact"),
42
68
  verbosity: settings.get("notifications.verbosity") === "verbose" ? "verbose" : "lean",
69
+ sessionScope: settings.get("notifications.sessionScope") === "primary" ? "primary" : "all",
43
70
  idleTimeoutMs: settings.get("notifications.daemon.idleTimeoutMs"),
44
71
  rich: {
45
72
  enabled: settings.get("notifications.telegram.rich.enabled"),
@@ -47,6 +74,9 @@ export function getNotificationConfig(settings: Settings): NotificationConfig {
47
74
  richDraft: {
48
75
  enabled: settings.get("notifications.telegram.richDraft.enabled"),
49
76
  },
77
+ topics: {
78
+ nameTemplate: settings.get("notifications.telegram.topics.nameTemplate"),
79
+ },
50
80
  };
51
81
  }
52
82
 
@@ -95,11 +125,25 @@ export function shouldRegisterNotificationsExtension(input: {
95
125
  parentTaskPrefix?: string;
96
126
  /** Role-agent type/name; present for task sessions even if depth metadata is lost. */
97
127
  currentAgentType?: string;
128
+ /**
129
+ * True when this session was launched by one of GJC's own programmatic
130
+ * separate-process child spawn sites (marked via {@link SPAWN_PROVENANCE_ENV}).
131
+ * Under `notifications.sessionScope = "primary"` such children are suppressed
132
+ * unless they explicitly opt in, so an interactive parent that fans out work
133
+ * does not flood the paired chat with topics for children the user never
134
+ * asked for. User-opened sessions (CLI/tmux/headless) never carry the marker.
135
+ */
136
+ spawnedByGjc?: boolean;
98
137
  }): boolean {
99
138
  if ((input.taskDepth ?? 0) > 0 || input.parentTaskPrefix || input.currentAgentType) return false;
100
139
  if (completionNotifyDisabledByEnv(input.env)) return false;
101
140
  if (input.env.GJC_NOTIFICATIONS === "0") return false;
102
141
  if (input.env.GJC_NOTIFICATIONS === "1" || input.env.GJC_NOTIFICATIONS_TOKEN) return true;
142
+ // Spawned-child suppression sits below explicit opt-in (so Telegram
143
+ // `/session_create` and cold `/session_resume`, which launch with
144
+ // GJC_NOTIFICATIONS=1, keep their fully bidirectional topic) and above global
145
+ // auto-on (so their children stay silent under `primary`).
146
+ if (input.spawnedByGjc && input.cfg?.sessionScope === "primary") return false;
103
147
  return input.cfg ? isGloballyConfigured(input.cfg) : false;
104
148
  }
105
149
 
@@ -1,5 +1,13 @@
1
1
  import * as path from "node:path";
2
2
 
3
+ /**
4
+ * Owner heartbeat freshness window. A daemon ownership record older than this
5
+ * (without a live pid) is considered stale. Lives here, in the lightweight
6
+ * paths module, so secret-safe consumers (e.g. the notification service) can
7
+ * reuse it without importing the heavy daemon runtime.
8
+ */
9
+ export const HEARTBEAT_TTL_MS = 20_000;
10
+
3
11
  export interface DaemonPaths {
4
12
  dir: string;
5
13
  lock: string;