@gajae-code/coding-agent 0.9.3 → 0.9.5
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 +16 -0
- package/dist/types/build-metadata.d.ts +7 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/modes/acp/acp-event-mapper.d.ts +2 -0
- package/dist/types/modes/components/hook-selector.d.ts +1 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -2
- package/dist/types/notifications/telegram-daemon.d.ts +2 -0
- package/package.json +7 -7
- package/scripts/compile-args.ts +10 -5
- package/src/build-metadata.ts +104 -0
- package/src/config/keybindings.ts +2 -2
- package/src/eval/js/executor.ts +7 -1
- package/src/modes/acp/acp-agent.ts +72 -16
- package/src/modes/acp/acp-client-bridge.ts +27 -5
- package/src/modes/acp/acp-event-mapper.ts +39 -1
- package/src/modes/components/hook-selector.ts +4 -0
- package/src/modes/components/model-selector.ts +17 -5
- package/src/modes/components/session-selector.ts +2 -2
- package/src/modes/components/status-line.ts +6 -5
- package/src/modes/components/welcome.ts +4 -1
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +6 -0
- package/src/modes/controllers/selector-controller.ts +7 -3
- package/src/modes/rpc/rpc-client.ts +30 -6
- package/src/notifications/telegram-daemon.ts +28 -2
- package/src/slash-commands/builtin-registry.ts +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.5] - 2026-07-09
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
- ACP permission prompts now honor `clientCapabilities._meta.gjc.permissionHandling` and the `GJC_ACP_PERMISSION_MODE` fallback, so `auto` and `always-allow` no longer emit `session/request_permission` calls while invalid values fail safely to `prompt`.
|
|
9
|
+
- Model selector batch assignments ("Set for all role agents" / "Set for all targets") now open the reasoning-effort menu whenever any batch target requires an explicit choice (e.g. Anthropic reasoning models like `claude-fable-5`), and the chosen effort plus the full batch survive the menu. Previously the menu never appeared for Anthropic models (silently persisting `:off` selectors for every role agent), and for OpenAI/Codex models picking an effort collapsed the batch to a DEFAULT-only assignment.
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- ACP clients now receive GJC automatic-compaction start/end state through additive `session_info_update` metadata, including the compaction action, trigger, retry/abort/error outcome, and busy-to-idle phase transitions.
|
|
13
|
+
|
|
14
|
+
## [0.9.4] - 2026-07-09
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `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.
|
|
18
|
+
- 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).
|
|
19
|
+
- 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).
|
|
20
|
+
|
|
5
21
|
## [0.9.3] - 2026-07-09
|
|
6
22
|
|
|
7
23
|
### 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"];
|
|
@@ -16,6 +16,8 @@ interface AcpEventMapperOptions {
|
|
|
16
16
|
* before emitting `ToolCallLocation` entries.
|
|
17
17
|
*/
|
|
18
18
|
cwd?: string;
|
|
19
|
+
/** Phase to expose after compaction ends. Prompt-bound compaction resumes responding; idle maintenance returns idle. */
|
|
20
|
+
compactionEndPhase?: "responding" | "idle";
|
|
19
21
|
}
|
|
20
22
|
export declare function mapToolKind(toolName: string): ToolKind;
|
|
21
23
|
export declare function mapAgentWireEventPayloadToAcpSessionUpdates(payload: AgentWireEventPayload, sessionId: string, options?: AcpEventMapperOptions): SessionNotification[];
|
|
@@ -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
|
|
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.
|
|
4
|
+
"version": "0.9.5",
|
|
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.
|
|
53
|
-
"@gajae-code/agent-core": "0.9.
|
|
54
|
-
"@gajae-code/ai": "0.9.
|
|
55
|
-
"@gajae-code/natives": "0.9.
|
|
56
|
-
"@gajae-code/tui": "0.9.
|
|
57
|
-
"@gajae-code/utils": "0.9.
|
|
52
|
+
"@gajae-code/stats": "0.9.5",
|
|
53
|
+
"@gajae-code/agent-core": "0.9.5",
|
|
54
|
+
"@gajae-code/ai": "0.9.5",
|
|
55
|
+
"@gajae-code/natives": "0.9.5",
|
|
56
|
+
"@gajae-code/tui": "0.9.5",
|
|
57
|
+
"@gajae-code/utils": "0.9.5",
|
|
58
58
|
"@puppeteer/browsers": "^2.13.0",
|
|
59
59
|
"@types/turndown": "5.0.6",
|
|
60
60
|
"@xterm/headless": "^6.0.0",
|
package/scripts/compile-args.ts
CHANGED
|
@@ -14,7 +14,10 @@ export const compileAutoloadDisableFlags = [
|
|
|
14
14
|
"--no-compile-autoload-package-json",
|
|
15
15
|
];
|
|
16
16
|
|
|
17
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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"],
|
package/src/eval/js/executor.ts
CHANGED
|
@@ -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 =
|
|
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 {
|
|
@@ -152,8 +152,8 @@ type ManagedSessionRecord = {
|
|
|
152
152
|
liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
|
|
153
153
|
toolArgsById: Map<string, unknown>;
|
|
154
154
|
extensionsConfigured: boolean;
|
|
155
|
-
// Installed
|
|
156
|
-
// in `#disposeSessionRecord`. Lives independent of any prompt turn.
|
|
155
|
+
// Installed after the bootstrap race guard or eagerly when the client starts a prompt;
|
|
156
|
+
// released in `#disposeSessionRecord`. Lives independent of any prompt turn.
|
|
157
157
|
lifetimeUnsubscribe: (() => void) | undefined;
|
|
158
158
|
};
|
|
159
159
|
|
|
@@ -619,6 +619,7 @@ export class AcpAgent implements Agent {
|
|
|
619
619
|
promise: pendingPrompt.promise,
|
|
620
620
|
};
|
|
621
621
|
|
|
622
|
+
this.#ensureLifetimeSubscription(record);
|
|
622
623
|
record.promptTurn.unsubscribe = record.session.subscribe(event => {
|
|
623
624
|
void this.#handlePromptEvent(record, event);
|
|
624
625
|
});
|
|
@@ -831,6 +832,7 @@ export class AcpAgent implements Agent {
|
|
|
831
832
|
}
|
|
832
833
|
promptTurn.cancelRequested = true;
|
|
833
834
|
promptTurn.unsubscribe?.();
|
|
835
|
+
void this.#emitCancelledPromptIdleUpdate(record);
|
|
834
836
|
const cleanup = this.#runCancelCleanup(record, promptTurn);
|
|
835
837
|
promptTurn.cleanup = cleanup;
|
|
836
838
|
this.#finishPrompt(record, {
|
|
@@ -1043,8 +1045,8 @@ export class AcpAgent implements Agent {
|
|
|
1043
1045
|
async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
|
|
1044
1046
|
const record = this.#createManagedSessionRecord(session);
|
|
1045
1047
|
session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities));
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
+
// The lifetime subscription normally follows the bootstrap race guard, but
|
|
1049
|
+
// prompt() installs it eagerly once the client has demonstrated session ownership.
|
|
1048
1050
|
try {
|
|
1049
1051
|
await this.#configureExtensions(record);
|
|
1050
1052
|
await this.#configureMcpServers(record, mcpServers);
|
|
@@ -1070,7 +1072,38 @@ export class AcpAgent implements Agent {
|
|
|
1070
1072
|
};
|
|
1071
1073
|
}
|
|
1072
1074
|
|
|
1075
|
+
#ensureLifetimeSubscription(record: ManagedSessionRecord): void {
|
|
1076
|
+
if (record.lifetimeUnsubscribe) {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
record.lifetimeUnsubscribe = record.session.subscribe(event => {
|
|
1080
|
+
void this.#handleLifetimeEvent(record, event);
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1073
1084
|
async #handleLifetimeEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
|
|
1085
|
+
if (event.type === "auto_compaction_start" || event.type === "auto_compaction_end") {
|
|
1086
|
+
// Prompt-bound compaction is normally forwarded by #handlePromptEvent. The lifetime
|
|
1087
|
+
// subscription covers idle maintenance and the end event after prompt cancellation.
|
|
1088
|
+
const promptTurn = record.promptTurn;
|
|
1089
|
+
if (
|
|
1090
|
+
isPromptTurnInFlight(promptTurn) &&
|
|
1091
|
+
(!promptTurn.cancelRequested || event.type === "auto_compaction_start")
|
|
1092
|
+
) {
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
for (const notification of mapAgentWireEventPayloadToAcpSessionUpdates(
|
|
1096
|
+
toAgentWireEventPayload(event),
|
|
1097
|
+
record.session.sessionId,
|
|
1098
|
+
{
|
|
1099
|
+
cwd: record.session.sessionManager.getCwd(),
|
|
1100
|
+
compactionEndPhase: "idle",
|
|
1101
|
+
},
|
|
1102
|
+
)) {
|
|
1103
|
+
await this.#connection.sessionUpdate(notification);
|
|
1104
|
+
}
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1074
1107
|
if (event.type !== "thinking_level_changed") {
|
|
1075
1108
|
return;
|
|
1076
1109
|
}
|
|
@@ -1140,6 +1173,7 @@ export class AcpAgent implements Agent {
|
|
|
1140
1173
|
getMessageProgress: message => this.#getLiveMessageProgress(record, message),
|
|
1141
1174
|
getToolArgs: toolCallId => record.toolArgsById.get(toolCallId),
|
|
1142
1175
|
cwd: record.session.sessionManager.getCwd(),
|
|
1176
|
+
compactionEndPhase: "responding",
|
|
1143
1177
|
},
|
|
1144
1178
|
)) {
|
|
1145
1179
|
await this.#connection.sessionUpdate(notification);
|
|
@@ -1551,13 +1585,10 @@ export class AcpAgent implements Agent {
|
|
|
1551
1585
|
// enough that the response future has scheduled before our timer fires
|
|
1552
1586
|
// on stdio-only transports.
|
|
1553
1587
|
//
|
|
1554
|
-
// The session-lifetime subscription
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
// hasn't been told about yet. The pre-bootstrap thinking level is
|
|
1559
|
-
// reported in the response's `configOptions`, so deferring the
|
|
1560
|
-
// notification loses no state.
|
|
1588
|
+
// The session-lifetime subscription normally shares this guard so extension
|
|
1589
|
+
// work cannot notify an unknown session id. prompt() may install it earlier:
|
|
1590
|
+
// receipt of a prompt proves the client already owns the returned session id,
|
|
1591
|
+
// and closes the cancellation window before this timer fires.
|
|
1561
1592
|
setTimeout(() => {
|
|
1562
1593
|
if (this.#connection.signal.aborted) {
|
|
1563
1594
|
return;
|
|
@@ -1566,11 +1597,7 @@ export class AcpAgent implements Agent {
|
|
|
1566
1597
|
if (!record) {
|
|
1567
1598
|
return;
|
|
1568
1599
|
}
|
|
1569
|
-
|
|
1570
|
-
record.lifetimeUnsubscribe = record.session.subscribe(event => {
|
|
1571
|
-
void this.#handleLifetimeEvent(record, event);
|
|
1572
|
-
});
|
|
1573
|
-
}
|
|
1600
|
+
this.#ensureLifetimeSubscription(record);
|
|
1574
1601
|
void this.#emitBootstrapUpdates(sessionId, record);
|
|
1575
1602
|
}, ACP_BOOTSTRAP_RACE_GUARD_MS);
|
|
1576
1603
|
}
|
|
@@ -1606,6 +1633,30 @@ export class AcpAgent implements Agent {
|
|
|
1606
1633
|
});
|
|
1607
1634
|
}
|
|
1608
1635
|
|
|
1636
|
+
/** Ensure cancellation clears a previously emitted busy/compacting phase even if abort never produces an end event. */
|
|
1637
|
+
async #emitCancelledPromptIdleUpdate(record: ManagedSessionRecord): Promise<void> {
|
|
1638
|
+
if (this.#connection.signal.aborted) {
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
try {
|
|
1642
|
+
await this.#connection.sessionUpdate({
|
|
1643
|
+
sessionId: record.session.sessionId,
|
|
1644
|
+
update: {
|
|
1645
|
+
sessionUpdate: "session_info_update",
|
|
1646
|
+
title: record.session.sessionName,
|
|
1647
|
+
updatedAt: new Date().toISOString(),
|
|
1648
|
+
_meta: {
|
|
1649
|
+
gjcPhase: "idle",
|
|
1650
|
+
running: false,
|
|
1651
|
+
gjcRunning: false,
|
|
1652
|
+
},
|
|
1653
|
+
},
|
|
1654
|
+
});
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
logger.warn("Failed to emit cancelled ACP prompt idle update", { error });
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1609
1660
|
/**
|
|
1610
1661
|
* Reload plugin/registry state for an ACP session. Mirrors the interactive
|
|
1611
1662
|
* `/reload-plugins` and `/move` flows: invalidates the plugin-roots cache,
|
|
@@ -1647,6 +1698,11 @@ export class AcpAgent implements Agent {
|
|
|
1647
1698
|
sessionUpdate: "session_info_update",
|
|
1648
1699
|
title: record.session.sessionName,
|
|
1649
1700
|
updatedAt: new Date().toISOString(),
|
|
1701
|
+
_meta: {
|
|
1702
|
+
gjcPhase: "idle",
|
|
1703
|
+
running: false,
|
|
1704
|
+
gjcRunning: false,
|
|
1705
|
+
},
|
|
1650
1706
|
},
|
|
1651
1707
|
});
|
|
1652
1708
|
}
|
|
@@ -22,18 +22,38 @@ import type {
|
|
|
22
22
|
ClientBridgeTerminalHandle,
|
|
23
23
|
} from "../../session/client-bridge";
|
|
24
24
|
|
|
25
|
+
type AcpPermissionMode = "auto" | "prompt" | "always-allow";
|
|
26
|
+
|
|
27
|
+
const ACP_PERMISSION_MODE_ENV = "GJC_ACP_PERMISSION_MODE";
|
|
28
|
+
|
|
29
|
+
function parseAcpPermissionMode(value: unknown): AcpPermissionMode {
|
|
30
|
+
if (value === "auto" || value === "prompt" || value === "always-allow") return value;
|
|
31
|
+
return "prompt";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Client metadata is authoritative; the process environment is only a fallback when that field is absent. */
|
|
35
|
+
function resolveAcpPermissionMode(clientCapabilities: ClientCapabilities | undefined): AcpPermissionMode {
|
|
36
|
+
const meta = clientCapabilities?._meta;
|
|
37
|
+
if (typeof meta === "object" && meta !== null) {
|
|
38
|
+
const gjc = (meta as { gjc?: unknown }).gjc;
|
|
39
|
+
if (typeof gjc === "object" && gjc !== null && "permissionHandling" in gjc) {
|
|
40
|
+
return parseAcpPermissionMode((gjc as { permissionHandling?: unknown }).permissionHandling);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return parseAcpPermissionMode(process.env[ACP_PERMISSION_MODE_ENV]);
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
export function createAcpClientBridge(
|
|
26
47
|
connection: AgentSideConnection,
|
|
27
48
|
sessionId: string,
|
|
28
49
|
clientCapabilities: ClientCapabilities | undefined,
|
|
29
50
|
): ClientBridge {
|
|
51
|
+
const promptPermission = resolveAcpPermissionMode(clientCapabilities) === "prompt";
|
|
30
52
|
const capabilities: ClientBridgeCapabilities = {
|
|
31
53
|
readTextFile: clientCapabilities?.fs?.readTextFile === true,
|
|
32
54
|
writeTextFile: clientCapabilities?.fs?.writeTextFile === true,
|
|
33
55
|
terminal: clientCapabilities?.terminal === true,
|
|
34
|
-
|
|
35
|
-
// the agent's policy choice rather than a client capability.
|
|
36
|
-
requestPermission: true,
|
|
56
|
+
requestPermission: promptPermission,
|
|
37
57
|
};
|
|
38
58
|
|
|
39
59
|
const bridge: ClientBridge = { capabilities, deferAgentInitiatedTurns: true };
|
|
@@ -65,8 +85,10 @@ export function createAcpClientBridge(
|
|
|
65
85
|
createTerminalHandle(connection, sessionId, params);
|
|
66
86
|
}
|
|
67
87
|
|
|
68
|
-
|
|
69
|
-
requestPermission
|
|
88
|
+
if (promptPermission) {
|
|
89
|
+
bridge.requestPermission = (toolCall, options, signal) =>
|
|
90
|
+
requestPermission(connection, sessionId, toolCall, options, signal);
|
|
91
|
+
}
|
|
70
92
|
|
|
71
93
|
return bridge;
|
|
72
94
|
}
|
|
@@ -27,6 +27,8 @@ interface AcpEventMapperOptions {
|
|
|
27
27
|
* before emitting `ToolCallLocation` entries.
|
|
28
28
|
*/
|
|
29
29
|
cwd?: string;
|
|
30
|
+
/** Phase to expose after compaction ends. Prompt-bound compaction resumes responding; idle maintenance returns idle. */
|
|
31
|
+
compactionEndPhase?: "responding" | "idle";
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
interface ContentArrayContainer {
|
|
@@ -236,8 +238,44 @@ export function mapAgentSessionEventToAcpSessionUpdates(
|
|
|
236
238
|
case "turn_start":
|
|
237
239
|
case "turn_end":
|
|
238
240
|
case "message_start":
|
|
241
|
+
return [];
|
|
239
242
|
case "auto_compaction_start":
|
|
240
|
-
|
|
243
|
+
return [
|
|
244
|
+
toSessionNotification(sessionId, {
|
|
245
|
+
sessionUpdate: "session_info_update",
|
|
246
|
+
_meta: {
|
|
247
|
+
gjcPhase: "compacting",
|
|
248
|
+
gjcCompactionState: "start",
|
|
249
|
+
gjcCompactionTrigger: event.reason,
|
|
250
|
+
gjcCompactionAction: event.action,
|
|
251
|
+
running: true,
|
|
252
|
+
gjcRunning: true,
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
];
|
|
256
|
+
case "auto_compaction_end": {
|
|
257
|
+
const phase = options.compactionEndPhase ?? "responding";
|
|
258
|
+
const running = phase !== "idle";
|
|
259
|
+
const meta: Record<string, unknown> = {
|
|
260
|
+
gjcPhase: phase,
|
|
261
|
+
gjcCompactionState: "end",
|
|
262
|
+
gjcCompactionAction: event.action,
|
|
263
|
+
gjcCompactionAborted: event.aborted,
|
|
264
|
+
gjcCompactionWillRetry: event.willRetry,
|
|
265
|
+
running,
|
|
266
|
+
gjcRunning: running,
|
|
267
|
+
};
|
|
268
|
+
if (event.skipped !== undefined) {
|
|
269
|
+
meta.gjcCompactionSkipped = event.skipped;
|
|
270
|
+
}
|
|
271
|
+
if (event.errorMessage !== undefined) {
|
|
272
|
+
meta.gjcCompactionErrorMessage = event.errorMessage;
|
|
273
|
+
}
|
|
274
|
+
if (event.continuationSkipReason !== undefined) {
|
|
275
|
+
meta.gjcCompactionContinuationSkipReason = event.continuationSkipReason;
|
|
276
|
+
}
|
|
277
|
+
return [toSessionNotification(sessionId, { sessionUpdate: "session_info_update", _meta: meta })];
|
|
278
|
+
}
|
|
241
279
|
case "auto_retry_start":
|
|
242
280
|
case "auto_retry_end":
|
|
243
281
|
case "retry_fallback_applied":
|
|
@@ -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);
|
|
@@ -121,6 +121,7 @@ export type ModelSelectorSelection =
|
|
|
121
121
|
interface PendingThinkingChoice {
|
|
122
122
|
item: ModelItem | CanonicalModelItem;
|
|
123
123
|
role: GjcModelAssignmentTargetId | null;
|
|
124
|
+
roles?: readonly GjcModelAssignmentTargetId[];
|
|
124
125
|
levels: ThinkingLevel[];
|
|
125
126
|
}
|
|
126
127
|
|
|
@@ -1373,7 +1374,13 @@ export class ModelSelectorComponent extends Container {
|
|
|
1373
1374
|
}
|
|
1374
1375
|
|
|
1375
1376
|
#renderThinkingMenu(choice: PendingThinkingChoice): void {
|
|
1376
|
-
const targetLabel = choice.
|
|
1377
|
+
const targetLabel = choice.roles
|
|
1378
|
+
? choice.roles.includes("default")
|
|
1379
|
+
? "all targets"
|
|
1380
|
+
: "all role agents"
|
|
1381
|
+
: choice.role === null
|
|
1382
|
+
? "temporary model"
|
|
1383
|
+
: GJC_MODEL_ASSIGNMENT_TARGETS[choice.role].name;
|
|
1377
1384
|
this.#listContainer.addChild(new Spacer(1));
|
|
1378
1385
|
this.#listContainer.addChild(
|
|
1379
1386
|
new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${choice.item.model.id}`), 0, 0),
|
|
@@ -1700,14 +1707,16 @@ export class ModelSelectorComponent extends Container {
|
|
|
1700
1707
|
const level = choice.levels[this.#selectedThinkingIndex];
|
|
1701
1708
|
if (!level) return;
|
|
1702
1709
|
this.#pendingThinkingChoice = undefined;
|
|
1703
|
-
this.#handleSelect(choice.item, choice.role, level);
|
|
1710
|
+
this.#handleSelect(choice.item, choice.role, level, choice.roles);
|
|
1704
1711
|
return;
|
|
1705
1712
|
}
|
|
1706
1713
|
if (getKeybindings().matches(keyData, "tui.select.cancel")) {
|
|
1707
1714
|
this.#pendingThinkingChoice = undefined;
|
|
1708
1715
|
if (choice.role !== null) {
|
|
1709
1716
|
this.#pendingActionItem = choice.item;
|
|
1710
|
-
this.#selectedActionIndex =
|
|
1717
|
+
this.#selectedActionIndex = choice.roles
|
|
1718
|
+
? GJC_MODEL_ASSIGNMENT_TARGET_IDS.length + (choice.roles.includes("default") ? 1 : 0)
|
|
1719
|
+
: Math.max(0, GJC_MODEL_ASSIGNMENT_TARGET_IDS.indexOf(choice.role));
|
|
1711
1720
|
}
|
|
1712
1721
|
this.#updateList();
|
|
1713
1722
|
}
|
|
@@ -1736,9 +1745,12 @@ export class ModelSelectorComponent extends Container {
|
|
|
1736
1745
|
): void {
|
|
1737
1746
|
const itemThinkingLevel = thinkingLevel ?? item.thinkingLevel;
|
|
1738
1747
|
const hasExplicitThinkingChoice = thinkingLevel !== undefined || item.explicitThinkingLevel === true;
|
|
1739
|
-
|
|
1748
|
+
const needsExplicitThinkingChoice = roles
|
|
1749
|
+
? roles.some(targetRole => requiresExplicitThinkingChoice(item.model, targetRole))
|
|
1750
|
+
: requiresExplicitThinkingChoice(item.model, role);
|
|
1751
|
+
if (!hasExplicitThinkingChoice && needsExplicitThinkingChoice) {
|
|
1740
1752
|
const levels = getSelectableThinkingLevels(item.model);
|
|
1741
|
-
this.#pendingThinkingChoice = { item, role, levels };
|
|
1753
|
+
this.#pendingThinkingChoice = { item, role, roles, levels };
|
|
1742
1754
|
this.#selectedThinkingIndex = this.#getInitialThinkingChoiceIndex(item, levels);
|
|
1743
1755
|
this.#updateList();
|
|
1744
1756
|
return;
|
|
@@ -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
|
-
|
|
933
|
-
|
|
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
|
|
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
|
|
1435
|
-
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
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)))
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|