@gajae-code/coding-agent 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.4] - 2026-07-09
6
+ ### Fixed
7
+
8
+ - `RpcClient.onSessionEvent()` now exposes the full renderer-facing agent-wire event stream instead of dropping non-core session events such as notices, todo reminders, retry events, subagent steering, thinking-level changes, and goal updates, while `onEvent()` remains the filtered legacy `AgentEvent` subscription path.
9
+ - Clarified `/session delete` help, confirmation, and completion wording to specify current vs. selected session transcript/artifact deletion and that other sessions plus topic/history metadata are not removed (#1913).
10
+ - Home screen build labels now come from install/build metadata, so release binaries and package installs no longer show a misleading dev build label (#1911).
11
+
5
12
  ## [0.9.3] - 2026-07-09
6
13
 
7
14
  ### Fixed
@@ -0,0 +1,7 @@
1
+ export type BuildChannel = "release" | "dev" | "local-source" | "package-install" | "compiled" | "unknown";
2
+ export interface BuildMetadata {
3
+ channel: BuildChannel;
4
+ label: string;
5
+ }
6
+ export declare function resolveBuildMetadata(moduleDir?: string): BuildMetadata;
7
+ export declare function formatBuildLabel(metadata?: BuildMetadata): string;
@@ -297,11 +297,11 @@ export declare const KEYBINDINGS: {
297
297
  };
298
298
  readonly "app.session.delete": {
299
299
  readonly defaultKeys: "ctrl+d";
300
- readonly description: "Delete session";
300
+ readonly description: "Delete current session transcript/artifacts";
301
301
  };
302
302
  readonly "app.session.deleteNoninvasive": {
303
303
  readonly defaultKeys: "ctrl+backspace";
304
- readonly description: "Delete session (non-invasive)";
304
+ readonly description: "Delete selected session transcript/artifacts (non-invasive)";
305
305
  };
306
306
  readonly "app.tree.foldOrUp": {
307
307
  readonly defaultKeys: ["ctrl+left", "alt+left"];
@@ -48,6 +48,7 @@ export interface HookSelectorOptions {
48
48
  export declare class HookSelectorComponent extends Container {
49
49
  #private;
50
50
  constructor(title: string, options: string[], onSelect: (option: string) => void, onCancel: () => void, opts?: HookSelectorOptions);
51
+ hasActiveInlineInput(): boolean;
51
52
  handleInput(keyData: string): void;
52
53
  dispose(): void;
53
54
  }
@@ -14,6 +14,7 @@ export interface WelcomeComponentOptions {
14
14
  getReservedBottomRows?: (termWidth: number) => number;
15
15
  changelogMarkdown?: string;
16
16
  collapseChangelog?: boolean;
17
+ buildLabel?: string;
17
18
  }
18
19
  /**
19
20
  * GJC-native launch surface with compact command affordances, project
@@ -7,7 +7,7 @@ import type { AgentEvent, AgentMessage, AgentToolResult, ThinkingLevel } from "@
7
7
  import type { CompactionResult } from "@gajae-code/agent-core/compaction";
8
8
  import type { ImageContent, Model } from "@gajae-code/ai";
9
9
  import type { BashResult } from "../../exec/bash-executor";
10
- import type { SessionStats } from "../../session/agent-session";
10
+ import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
11
11
  import type { RpcExtensionUIRequest, RpcGetStateInclude, RpcHandoffResult, RpcHostToolDefinition, RpcSessionState, RpcUnattendedAccepted, RpcUnattendedDeclaration, RpcWorkflowGate, RpcWorkflowGateResolution } from "./rpc-types";
12
12
  export interface RpcClientOptions {
13
13
  /** Dial an existing Unix-domain socket instead of spawning a stdio child. */
@@ -37,6 +37,7 @@ export interface RpcClientOptions {
37
37
  }
38
38
  export type ModelInfo = Pick<Model, "provider" | "id" | "contextWindow" | "reasoning" | "thinking">;
39
39
  export type RpcEventListener = (event: AgentEvent) => void;
40
+ export type RpcSessionEventListener = (event: AgentSessionEvent) => void;
40
41
  export interface RpcClientToolContext<TDetails = unknown> {
41
42
  toolCallId: string;
42
43
  signal: AbortSignal;
@@ -72,9 +73,13 @@ export declare class RpcClient {
72
73
  */
73
74
  [Symbol.dispose](): void;
74
75
  /**
75
- * Subscribe to agent events.
76
+ * Subscribe to the legacy core AgentEvent subset.
76
77
  */
77
78
  onEvent(listener: RpcEventListener): () => void;
79
+ /**
80
+ * Subscribe to all renderer-facing agent session events.
81
+ */
82
+ onSessionEvent(listener: RpcSessionEventListener): () => void;
78
83
  /**
79
84
  * Subscribe to workflow lifecycle gates emitted by RPC mode.
80
85
  */
@@ -375,6 +375,8 @@ export declare class TelegramNotificationDaemon {
375
375
  private rememberPendingThreadedFrame;
376
376
  private flushPendingThreadedFrames;
377
377
  private ensureTopic;
378
+ private topicPastOrphanGrace;
379
+ private deleteOrphanedTopic;
378
380
  private deleteTopic;
379
381
  private persistTopics;
380
382
  loadTopics(): Promise<void>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/coding-agent",
4
- "version": "0.9.3",
4
+ "version": "0.9.4",
5
5
  "description": "Gajae Code CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -49,12 +49,12 @@
49
49
  "@agentclientprotocol/sdk": "0.21.0",
50
50
  "@babel/parser": "^7.29.3",
51
51
  "@mozilla/readability": "^0.6.0",
52
- "@gajae-code/stats": "0.9.3",
53
- "@gajae-code/agent-core": "0.9.3",
54
- "@gajae-code/ai": "0.9.3",
55
- "@gajae-code/natives": "0.9.3",
56
- "@gajae-code/tui": "0.9.3",
57
- "@gajae-code/utils": "0.9.3",
52
+ "@gajae-code/stats": "0.9.4",
53
+ "@gajae-code/agent-core": "0.9.4",
54
+ "@gajae-code/ai": "0.9.4",
55
+ "@gajae-code/natives": "0.9.4",
56
+ "@gajae-code/tui": "0.9.4",
57
+ "@gajae-code/utils": "0.9.4",
58
58
  "@puppeteer/browsers": "^2.13.0",
59
59
  "@types/turndown": "5.0.6",
60
60
  "@xterm/headless": "^6.0.0",
@@ -14,7 +14,10 @@ export const compileAutoloadDisableFlags = [
14
14
  "--no-compile-autoload-package-json",
15
15
  ];
16
16
 
17
- export const compiledDefineFlags = ['process.env.PI_COMPILED="true"'];
17
+ const compiledDefineFlags = ['process.env.PI_COMPILED="true"'];
18
+ const releaseDefineFlags = [...compiledDefineFlags, 'process.env.GJC_BUILD_CHANNEL="release"'];
19
+ const devDefineFlags = [...compiledDefineFlags, 'process.env.GJC_BUILD_CHANNEL="dev"'];
20
+
18
21
  export const compiledExternalPackages = ["mupdf"];
19
22
 
20
23
  export const releaseEntrypoints = [
@@ -24,7 +27,9 @@ export const releaseEntrypoints = [
24
27
  "./packages/coding-agent/src/eval/js/worker-entry.ts",
25
28
  "./packages/natives/native/index.js",
26
29
  "./packages/coding-agent/src/notifications/telegram-daemon-cli.ts",
27
- "./packages/ai/src/models.json",
30
+ // NOTE: models.json must NOT be listed here — `bun build --compile` does not
31
+ // emit `.json` extra entrypoints into the bunfs. It is embedded via the
32
+ // `with { type: "file" }` import in packages/ai/src/models.ts instead.
28
33
  "./node_modules/handlebars/lib/index.js",
29
34
  ];
30
35
 
@@ -34,7 +39,6 @@ export const devEntrypoints = [
34
39
  "./src/tools/browser/tab-worker-entry.ts",
35
40
  "./src/eval/js/worker-entry.ts",
36
41
  "./src/notifications/telegram-daemon-cli.ts",
37
- "../ai/src/models.json",
38
42
  "../../node_modules/handlebars/lib/index.js",
39
43
  ];
40
44
 
@@ -44,7 +48,8 @@ export function buildReleaseCompileArgs(target: string, outfile: string): string
44
48
  entrypoints: releaseEntrypoints,
45
49
  outfile,
46
50
  target,
47
- defines: compiledDefineFlags,
51
+ defines: releaseDefineFlags,
52
+
48
53
  externals: compiledExternalPackages,
49
54
  });
50
55
  }
@@ -54,7 +59,7 @@ export function buildDevCompileArgs(outfile = "dist/gjc"): string[] {
54
59
  root: "../..",
55
60
  entrypoints: devEntrypoints,
56
61
  outfile,
57
- defines: compiledDefineFlags,
62
+ defines: devDefineFlags,
58
63
  externals: compiledExternalPackages,
59
64
  });
60
65
  }
@@ -0,0 +1,104 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { isCompiledBinary } from "@gajae-code/utils/env";
4
+
5
+ export type BuildChannel = "release" | "dev" | "local-source" | "package-install" | "compiled" | "unknown";
6
+
7
+ export interface BuildMetadata {
8
+ channel: BuildChannel;
9
+ label: string;
10
+ }
11
+
12
+ const REPO_MARKERS = [".git", "bun.lock"];
13
+ const SOURCE_REPO_PACKAGE_NAME = "gajae-code";
14
+
15
+ export function resolveBuildMetadata(moduleDir: string = import.meta.dir): BuildMetadata {
16
+ const explicitChannel = normalizeBuildChannel(process.env.GJC_BUILD_CHANNEL);
17
+ if (explicitChannel) {
18
+ return metadataForChannel(explicitChannel);
19
+ }
20
+
21
+ if (isCompiledBinary()) {
22
+ return metadataForChannel("compiled");
23
+ }
24
+
25
+ if (isLocalSourceTree(moduleDir)) {
26
+ return metadataForChannel("local-source");
27
+ }
28
+
29
+ return metadataForChannel("package-install");
30
+ }
31
+
32
+ export function formatBuildLabel(metadata: BuildMetadata = resolveBuildMetadata()): string {
33
+ return metadata.label;
34
+ }
35
+
36
+ function normalizeBuildChannel(value: string | undefined): BuildChannel | undefined {
37
+ const normalized = value?.trim().toLowerCase();
38
+ if (!normalized) return undefined;
39
+ switch (normalized) {
40
+ case "release":
41
+ case "stable":
42
+ return "release";
43
+ case "dev":
44
+ case "development":
45
+ return "dev";
46
+ case "local-source":
47
+ case "source":
48
+ case "local":
49
+ return "local-source";
50
+ case "package-install":
51
+ case "package":
52
+ case "npm":
53
+ return "package-install";
54
+ case "compiled":
55
+ return "compiled";
56
+ case "unknown":
57
+ return "unknown";
58
+ default:
59
+ return "unknown";
60
+ }
61
+ }
62
+
63
+ function metadataForChannel(channel: BuildChannel): BuildMetadata {
64
+ switch (channel) {
65
+ case "release":
66
+ return { channel, label: "release build" };
67
+ case "dev":
68
+ return { channel, label: "dev build" };
69
+ case "local-source":
70
+ return { channel, label: "local source" };
71
+ case "package-install":
72
+ return { channel, label: "package install" };
73
+ case "compiled":
74
+ return { channel, label: "compiled build" };
75
+ case "unknown":
76
+ return { channel, label: "build unknown" };
77
+ }
78
+ }
79
+
80
+ function isLocalSourceTree(startDir: string): boolean {
81
+ let current = path.resolve(startDir);
82
+ while (true) {
83
+ if (hasRepoMarkers(current)) return true;
84
+ const parent = path.dirname(current);
85
+ if (parent === current) return false;
86
+ current = parent;
87
+ }
88
+ }
89
+
90
+ function hasRepoMarkers(dir: string): boolean {
91
+ if (!REPO_MARKERS.every(marker => fs.existsSync(path.join(dir, marker)))) return false;
92
+ return readPackageName(path.join(dir, "package.json")) === SOURCE_REPO_PACKAGE_NAME;
93
+ }
94
+
95
+ function readPackageName(packageJsonPath: string): string | undefined {
96
+ try {
97
+ const parsed: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
98
+ if (typeof parsed !== "object" || parsed === null || !("name" in parsed)) return undefined;
99
+ const name = parsed.name;
100
+ return typeof name === "string" ? name : undefined;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
@@ -188,11 +188,11 @@ export const KEYBINDINGS = {
188
188
  },
189
189
  "app.session.delete": {
190
190
  defaultKeys: "ctrl+d",
191
- description: "Delete session",
191
+ description: "Delete current session transcript/artifacts",
192
192
  },
193
193
  "app.session.deleteNoninvasive": {
194
194
  defaultKeys: "ctrl+backspace",
195
- description: "Delete session (non-invasive)",
195
+ description: "Delete selected session transcript/artifacts (non-invasive)",
196
196
  },
197
197
  "app.tree.foldOrUp": {
198
198
  defaultKeys: ["ctrl+left", "alt+left"],
@@ -1,6 +1,7 @@
1
1
  import { DEFAULT_MAX_BYTES, OutputSink } from "../../session/streaming-output";
2
2
  import type { ToolSession } from "../../tools";
3
3
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
4
+ import { ToolError } from "../../tools/tool-errors";
4
5
  import { executeInVmContext, type JsDisplayOutput } from "./context-manager";
5
6
 
6
7
  export interface JsExecutorOptions {
@@ -117,7 +118,12 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
117
118
  displayOutputs,
118
119
  };
119
120
  }
120
- const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
121
+ const message =
122
+ error instanceof ToolError || (error instanceof Error && error.name === "ToolError")
123
+ ? error.message
124
+ : error instanceof Error
125
+ ? (error.stack ?? error.message)
126
+ : String(error);
121
127
  outputSink.push(message);
122
128
  const summary = await outputSink.dump();
123
129
  return {
@@ -434,6 +434,10 @@ export class HookSelectorComponent extends Container {
434
434
  this.#updateList();
435
435
  }
436
436
 
437
+ hasActiveInlineInput(): boolean {
438
+ return this.#inlineEditor !== undefined;
439
+ }
440
+
437
441
  #updateList(): void {
438
442
  if (this.#wrapFocused && this.#focusAwareList) {
439
443
  this.#focusAwareList.setState(this.#options, this.#selectedIndex, this.#maxVisible);
@@ -180,7 +180,7 @@ class SessionList implements Component {
180
180
 
181
181
  // Add keybinding hint
182
182
  lines.push("");
183
- lines.push(theme.fg("muted", " [Del to delete, Enter to select, Esc to cancel]"));
183
+ lines.push(theme.fg("muted", " [Del to delete selected transcript/artifacts, Enter to select, Esc to cancel]"));
184
184
 
185
185
  return lines;
186
186
  }
@@ -307,7 +307,7 @@ export class SessionSelectorComponent extends Container {
307
307
  #showDeleteConfirmation(session: SessionInfo): void {
308
308
  const displayName = session.title || session.firstMessage.slice(0, 40) || session.id;
309
309
  this.#confirmationDialog = new HookSelectorComponent(
310
- `Delete session?\n${displayName}`,
310
+ `Delete selected session transcript and artifacts?\n${displayName}\nThis cannot be undone. Other sessions and topic/history metadata are not deleted.`,
311
311
  ["Yes", "No"],
312
312
  async (option: string) => {
313
313
  if (option === "Yes" && this.#onDelete) {
@@ -929,11 +929,8 @@ export class StatusLineComponent implements Component {
929
929
  const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
930
930
 
931
931
  if (topFillWidth > 0) {
932
- while (totalWidth() > topFillWidth && right.length > 0) {
933
- right.pop();
934
- rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
935
- }
936
- // Shrink path before dropping left segments — path is the only elastic segment
932
+ // Shrink path before dropping right-side telemetry — path is the only elastic segment,
933
+ // and presets such as default-usage should not hide usage just because cwd is long.
937
934
  const pathIdx = leftIds.indexOf("path");
938
935
  if (pathIdx >= 0 && totalWidth() > topFillWidth) {
939
936
  const overflow = totalWidth() - topFillWidth;
@@ -943,6 +940,10 @@ export class StatusLineComponent implements Component {
943
940
  leftWidth = this.#groupWidth(left, leftCapWidth, leftSepWidth);
944
941
  }
945
942
  }
943
+ while (totalWidth() > topFillWidth && right.length > 0) {
944
+ right.pop();
945
+ rightWidth = this.#groupWidth(right, rightCapWidth, rightSepWidth);
946
+ }
946
947
  while (totalWidth() > topFillWidth && left.length > 0) {
947
948
  left.pop();
948
949
  leftIds.pop();
@@ -1,5 +1,6 @@
1
1
  import { type Component, padding, TERMINAL, truncateToWidth, visibleWidth } from "@gajae-code/tui";
2
2
  import { APP_NAME } from "@gajae-code/utils";
3
+ import { formatBuildLabel } from "../../build-metadata";
3
4
  import { type ThemeColor, theme } from "../../modes/theme/theme";
4
5
 
5
6
  export interface RecentSession {
@@ -19,6 +20,7 @@ export interface WelcomeComponentOptions {
19
20
  getReservedBottomRows?: (termWidth: number) => number;
20
21
  changelogMarkdown?: string;
21
22
  collapseChangelog?: boolean;
23
+ buildLabel?: string;
22
24
  }
23
25
 
24
26
  const WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS = 9;
@@ -227,7 +229,8 @@ export class WelcomeComponent implements Component {
227
229
  const br = theme.fg("dim", theme.boxRound.bottomRight);
228
230
 
229
231
  const lines: string[] = [];
230
- const title = ` ${APP_NAME} v${this.version} · GJC Forge `;
232
+ const buildLabel = this.options.buildLabel ?? formatBuildLabel();
233
+ const title = ` ${APP_NAME} v${this.version} · ${buildLabel} · GJC Forge `;
231
234
  const titlePrefixRaw = hChar.repeat(3);
232
235
  const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
233
236
  const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
@@ -567,7 +567,7 @@ export class CommandController {
567
567
  `| Start a fresh session | \`${sessionNewKey}\` or \`/new\` |`,
568
568
  "| Resume another session | `/resume` |",
569
569
  "| Show session details | `/session info` |",
570
- "| Delete current session | `/session delete` |",
570
+ "| Delete current session transcript/artifacts | `/session delete` |",
571
571
  "| Select a model | `/model` or `Ctrl+L` |",
572
572
  "| Show all shortcuts | `?` on an empty prompt or `/hotkeys` |",
573
573
  "",
@@ -149,6 +149,12 @@ export class InputController {
149
149
  return { consume: true };
150
150
  }
151
151
  const hookDialogActive = this.#hasHookDialog();
152
+ if (this.ctx.hookSelector?.hasActiveInlineInput?.() === true) {
153
+ // Inline ask/custom-input editors use Esc to return to the option list.
154
+ // Let the focused selector see the key instead of converting a typo
155
+ // into a full workflow/session abort while the agent is streaming.
156
+ return undefined;
157
+ }
152
158
  if (
153
159
  this.#handleCancellableWorkEscape({
154
160
  loading: hookDialogActive,
@@ -1431,8 +1431,12 @@ export class SelectorController {
1431
1431
  }
1432
1432
 
1433
1433
  const confirmed = await this.ctx.showHookConfirm(
1434
- "Delete Session",
1435
- "This will permanently delete the current session.\nYou will be returned to the session selector.",
1434
+ "Delete current session transcript and artifacts?",
1435
+ [
1436
+ "This permanently deletes only the current session transcript file and its artifacts directory.",
1437
+ "Other sessions and topic/history metadata are not deleted.",
1438
+ "You will be moved to a fresh session and returned to the session selector.",
1439
+ ].join("\n"),
1436
1440
  );
1437
1441
 
1438
1442
  if (!confirmed) {
@@ -1449,7 +1453,7 @@ export class SelectorController {
1449
1453
  await storage.deleteSessionWithArtifacts(sessionFile);
1450
1454
 
1451
1455
  // Show session selector
1452
- this.ctx.showStatus("Session deleted");
1456
+ this.ctx.showStatus("Current session transcript and artifacts deleted");
1453
1457
  await this.showSessionSelector();
1454
1458
  }
1455
1459
 
@@ -8,7 +8,8 @@ import type { CompactionResult } from "@gajae-code/agent-core/compaction";
8
8
  import type { ImageContent, Model } from "@gajae-code/ai";
9
9
  import { isRecord, ptree, readJsonl } from "@gajae-code/utils";
10
10
  import type { BashResult } from "../../exec/bash-executor";
11
- import type { SessionStats } from "../../session/agent-session";
11
+ import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
12
+ import { AGENT_WIRE_EVENT_TYPES, type AgentWireEventType } from "../shared/agent-wire/event-contract";
12
13
  import type {
13
14
  RpcCommand,
14
15
  RpcExtensionUIRequest,
@@ -64,6 +65,7 @@ export interface RpcClientOptions {
64
65
  export type ModelInfo = Pick<Model, "provider" | "id" | "contextWindow" | "reasoning" | "thinking">;
65
66
 
66
67
  export type RpcEventListener = (event: AgentEvent) => void;
68
+ export type RpcSessionEventListener = (event: AgentSessionEvent) => void;
67
69
 
68
70
  export interface RpcClientToolContext<TDetails = unknown> {
69
71
  toolCallId: string;
@@ -91,7 +93,8 @@ export function defineRpcClientTool<
91
93
  return tool;
92
94
  }
93
95
 
94
- const agentEventTypes = new Set<AgentEvent["type"]>([
96
+ const agentWireEventTypes = new Set<AgentWireEventType>(AGENT_WIRE_EVENT_TYPES);
97
+ const coreAgentEventTypes = new Set<AgentEvent["type"]>([
95
98
  "agent_start",
96
99
  "agent_end",
97
100
  "turn_start",
@@ -116,13 +119,16 @@ function isRpcResponse(value: unknown): value is RpcResponse {
116
119
  return true;
117
120
  }
118
121
 
119
- function isAgentEvent(value: unknown): value is AgentEvent {
122
+ function isAgentSessionEvent(value: unknown): value is AgentSessionEvent {
120
123
  if (!isRecord(value)) return false;
121
124
  const type = value.type;
122
125
  if (typeof type !== "string") return false;
123
- return agentEventTypes.has(type as AgentEvent["type"]);
126
+ return agentWireEventTypes.has(type as AgentWireEventType);
124
127
  }
125
128
 
129
+ function isCoreAgentEvent(value: AgentSessionEvent): value is AgentEvent {
130
+ return coreAgentEventTypes.has(value.type as AgentEvent["type"]);
131
+ }
126
132
  function isRpcHostToolCallRequest(value: unknown): value is RpcHostToolCallRequest {
127
133
  if (!isRecord(value)) return false;
128
134
  return (
@@ -378,6 +384,7 @@ export class RpcClient {
378
384
  #transport: RpcTransport | null = null;
379
385
  #transportClosed = false;
380
386
  #eventListeners: RpcEventListener[] = [];
387
+ #sessionEventListeners: RpcSessionEventListener[] = [];
381
388
  #pendingRequests: Map<string, { resolve: (response: RpcResponse) => void; reject: (error: Error) => void }> =
382
389
  new Map();
383
390
  #customTools: RpcClientCustomTool[] = [];
@@ -442,7 +449,7 @@ export class RpcClient {
442
449
  }
443
450
 
444
451
  /**
445
- * Subscribe to agent events.
452
+ * Subscribe to the legacy core AgentEvent subset.
446
453
  */
447
454
  onEvent(listener: RpcEventListener): () => void {
448
455
  this.#eventListeners.push(listener);
@@ -454,6 +461,19 @@ export class RpcClient {
454
461
  };
455
462
  }
456
463
 
464
+ /**
465
+ * Subscribe to all renderer-facing agent session events.
466
+ */
467
+ onSessionEvent(listener: RpcSessionEventListener): () => void {
468
+ this.#sessionEventListeners.push(listener);
469
+ return () => {
470
+ const index = this.#sessionEventListeners.indexOf(listener);
471
+ if (index !== -1) {
472
+ this.#sessionEventListeners.splice(index, 1);
473
+ }
474
+ };
475
+ }
476
+
457
477
  /**
458
478
  * Subscribe to workflow lifecycle gates emitted by RPC mode.
459
479
  */
@@ -928,8 +948,12 @@ export class RpcClient {
928
948
 
929
949
  // Canonical agent-wire event frame: { type:"event", payload:{ event_type, event } }.
930
950
  const event = unwrapAgentWireEventFrame(data);
931
- if (!isAgentEvent(event)) return;
951
+ if (!isAgentSessionEvent(event)) return;
932
952
 
953
+ for (const listener of this.#sessionEventListeners) {
954
+ listener(event);
955
+ }
956
+ if (!isCoreAgentEvent(event)) return;
933
957
  for (const listener of this.#eventListeners) {
934
958
  listener(event);
935
959
  }
@@ -146,6 +146,7 @@ const TYPING_REFRESH_INTERVAL_MS = 4_000;
146
146
  const QUEUED_REACTION = "👀";
147
147
  const PENDING_TOPIC_FRAME_LIMIT = 20;
148
148
  const SEEN_UPDATE_ID_LIMIT = 1_000;
149
+ const ORPHAN_TOPIC_GRACE_MS = 60_000;
149
150
  const CONSUMED_REACTION = "✅";
150
151
 
151
152
  function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): string[] {
@@ -1414,24 +1415,33 @@ export class TelegramNotificationDaemon {
1414
1415
  async scanRoots(): Promise<void> {
1415
1416
  const paths = daemonPaths(this.opts.settings.getAgentDir());
1416
1417
  const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
1418
+ const endpointSessionIds = new Set<string>();
1419
+ let allRootsReadable = true;
1417
1420
  for (const root of rootState?.roots ?? []) {
1418
1421
  const dir = path.join(root, "notifications");
1419
1422
  let files: string[];
1420
1423
  try {
1421
1424
  files = await this.fsImpl.readdir(dir);
1422
1425
  } catch {
1426
+ allRootsReadable = false;
1423
1427
  continue;
1424
1428
  }
1425
1429
  for (const file of files.filter(item => item.endsWith(".json"))) {
1426
1430
  const sessionId = path.basename(file, ".json");
1431
+ endpointSessionIds.add(sessionId);
1427
1432
  if (this.sessions.has(sessionId)) continue;
1428
1433
  try {
1429
1434
  const endpoint = readEndpoint(path.join(dir, file));
1430
1435
  // Skip endpoint files whose owning process is gone or that are
1431
1436
  // explicitly stale (e.g. a hard-closed session): reconnecting
1432
- // would chase a dead, token-bearing record forever.
1437
+ // would chase a dead, token-bearing record forever. Once the
1438
+ // associated topic is past the grace window, reap it through the
1439
+ // same best-effort delete path as graceful session shutdown.
1433
1440
  const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
1434
- if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) continue;
1441
+ if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) {
1442
+ await this.deleteOrphanedTopic(sessionId);
1443
+ continue;
1444
+ }
1435
1445
  const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
1436
1446
  if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
1437
1447
  this.closedEndpointKeys.delete(sessionId);
@@ -1439,6 +1449,12 @@ export class TelegramNotificationDaemon {
1439
1449
  } catch {}
1440
1450
  }
1441
1451
  }
1452
+ if (allRootsReadable) {
1453
+ for (const sessionId of this.topics.sessionIds()) {
1454
+ if (!this.sessions.has(sessionId) && !endpointSessionIds.has(sessionId))
1455
+ await this.deleteOrphanedTopic(sessionId);
1456
+ }
1457
+ }
1442
1458
  }
1443
1459
 
1444
1460
  connectSession(sessionId: string, url: string, token: string): void {
@@ -1683,6 +1699,16 @@ export class TelegramNotificationDaemon {
1683
1699
  }
1684
1700
  }
1685
1701
 
1702
+ private topicPastOrphanGrace(sessionId: string): boolean {
1703
+ const record = this.topics.get(sessionId);
1704
+ return record !== undefined && this.runtime.now() - record.createdAt >= ORPHAN_TOPIC_GRACE_MS;
1705
+ }
1706
+
1707
+ private async deleteOrphanedTopic(sessionId: string): Promise<void> {
1708
+ if (!this.topicPastOrphanGrace(sessionId)) return;
1709
+ await this.deleteTopic(sessionId);
1710
+ }
1711
+
1686
1712
  /** Best-effort delete of a session topic once its local notification endpoint shuts down. */
1687
1713
  private async deleteTopic(sessionId: string): Promise<void> {
1688
1714
  const record = this.topics.get(sessionId);
@@ -810,12 +810,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
810
810
  {
811
811
  name: "session",
812
812
  priority: 88,
813
- description: "Show current session info or delete current session",
813
+ description: "Show session info or delete the current session transcript/artifacts",
814
814
  acpDescription: "Show session information",
815
815
  acpInputHint: "info|delete",
816
816
  subcommands: [
817
817
  { name: "info", description: "Show current session id, title, and workspace" },
818
- { name: "delete", description: "Delete current session and return to selector" },
818
+ { name: "delete", description: "Delete current session transcript and artifacts" },
819
819
  ],
820
820
  allowArgs: true,
821
821
  handle: async (command, runtime) => {
@@ -844,7 +844,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
844
844
  return usage(`Failed to delete session: ${errorMessage(err)}`, runtime);
845
845
  }
846
846
  await runtime.output(
847
- `Session deleted: ${sessionFile}. Use ACP \`session/load\` to switch to another session.`,
847
+ [
848
+ `Deleted current session transcript and artifacts: ${sessionFile}`,
849
+ "Other sessions and topic/history metadata were not deleted.",
850
+ ].join("\n"),
848
851
  );
849
852
  return commandConsumed();
850
853
  }
@@ -1020,8 +1023,10 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1020
1023
  return commandConsumed();
1021
1024
  }
1022
1025
  if (args === "login" || args.startsWith("login ")) {
1026
+ const providerId = args.slice("login".length).trim();
1027
+ const loginCommand = providerId ? `/login ${providerId}` : "/login [provider-id]";
1023
1028
  await runtime.output(
1024
- "Use the terminal UI /login selector for browser, device-code, or manual callback provider login.",
1029
+ `Open the terminal UI and run ${loginCommand} for OAuth/subscription account login. Paste callbacks with /login <redirect URL or code>.`,
1025
1030
  );
1026
1031
  return commandConsumed();
1027
1032
  }