@gajae-code/coding-agent 0.15.5 → 0.15.6
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 +26 -1
- package/dist/types/async/job-manager.d.ts +83 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
- package/dist/types/cli/model-presets-cli.d.ts +9 -0
- package/dist/types/cli/update-cli.d.ts +2 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +96 -0
- package/dist/types/config/file-lock.d.ts +49 -2
- package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
- package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +3 -2
- package/dist/types/config/model-preset-registry.d.ts +368 -0
- package/dist/types/config/model-profile-activation.d.ts +39 -0
- package/dist/types/config/model-profiles.d.ts +5 -3
- package/dist/types/config/model-registry.d.ts +11 -5
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
- package/dist/types/extensibility/extensions/types.d.ts +2 -5
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/sdk/broker/broker.d.ts +3 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/model-pin.d.ts +7 -2
- package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +15 -1
- package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
- package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +9 -0
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +19 -7
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
- package/dist/types/session/sdk-run-capability.d.ts +2 -0
- package/dist/types/telemetry/control.d.ts +11 -0
- package/dist/types/telemetry/events.d.ts +20 -0
- package/dist/types/telemetry/index.d.ts +3 -0
- package/dist/types/telemetry/transport.d.ts +22 -0
- package/dist/types/tools/bash-interactive.d.ts +23 -0
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/web/search/providers/base.d.ts +9 -4
- package/dist/types/web/search/providers/xai.d.ts +2 -1
- package/dist/types/web/search/types.d.ts +14 -0
- package/package.json +10 -7
- package/scripts/generate-hotkeys-docs.ts +4 -0
- package/scripts/generate-sdk-operation-inventory.ts +3 -3
- package/src/async/job-manager.ts +671 -39
- package/src/cli/auth-broker-cli.ts +40 -12
- package/src/cli/auth-gateway-cli.ts +273 -97
- package/src/cli/config-cli.ts +2 -2
- package/src/cli/fast-help.ts +3 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/update-cli.ts +40 -5
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/model-presets.ts +24 -0
- package/src/config/autorouting-tier-map.ts +32 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +775 -59
- package/src/config/internal/model-preset-registry-test-state.ts +28 -0
- package/src/config/internal/model-preset-registry-test-support.ts +11 -0
- package/src/config/keybindings.ts +6 -2
- package/src/config/model-preset-registry.ts +2799 -0
- package/src/config/model-profile-activation.ts +115 -28
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +51 -9
- package/src/config/model-registry.ts +1021 -122
- package/src/config/settings-schema.ts +11 -0
- package/src/config/settings.ts +6 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/projection-scan.ts +571 -0
- package/src/coordinator-mcp/server.ts +19 -24
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- package/src/extensibility/extensions/loader.ts +0 -1
- package/src/extensibility/extensions/types.ts +2 -3
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
- package/src/gjc-runtime/empty-delete-gc.ts +301 -0
- package/src/gjc-runtime/gc-render.ts +29 -0
- package/src/gjc-runtime/gc-runtime.ts +174 -11
- package/src/gjc-runtime/session-state-lock.ts +1242 -141
- package/src/gjc-runtime/session-state-sidecar.ts +76 -3
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +9 -2
- package/src/modes/acp/acp-agent.ts +11 -5
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/status-line/segments.ts +6 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +23 -6
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/utils/injected-user-submission.ts +15 -1
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/fold-receipt.md +3 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +17 -2
- package/src/sdk/broker/broker.ts +48 -21
- package/src/sdk/broker/lifecycle.ts +32 -6
- package/src/sdk/bus/chat-daemon-control.ts +2 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
- package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
- package/src/sdk/bus/prompt-reconciliation.ts +28 -4
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/cli/session-cli.ts +12 -1
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +3 -0
- package/src/sdk/host/model-pin.ts +185 -25
- package/src/sdk/host/sdk-run-capability.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +371 -37
- package/src/sdk/host/session-runtime.ts +364 -52
- package/src/sdk/prompt-deadline-manager.ts +16 -1
- package/src/sdk/protocol/adapter-validation.ts +27 -0
- package/src/sdk/protocol/operation-inventory.generated.json +2 -2
- package/src/sdk/protocol/operation-registry.ts +4 -1
- package/src/sdk/router/session-router.ts +228 -96
- package/src/sdk/session.ts +83 -12
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +678 -103
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/sdk-run-capability-internal.ts +15 -0
- package/src/session/sdk-run-capability.ts +2 -0
- package/src/session/session-manager.ts +5 -4
- package/src/setup/provider-presets.json +1 -1
- package/src/slash-commands/helpers/credential-switch.ts +6 -1
- package/src/task/executor.ts +19 -3
- package/src/telemetry/control.ts +26 -0
- package/src/telemetry/events.ts +142 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/transport.ts +87 -0
- package/src/tools/bash-interactive.ts +182 -85
- package/src/tools/bash.ts +772 -67
- package/src/tools/index.ts +7 -5
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/web/search/provider.ts +120 -11
- package/src/web/search/providers/anthropic.ts +89 -17
- package/src/web/search/providers/base.ts +9 -4
- package/src/web/search/providers/gemini.ts +87 -15
- package/src/web/search/providers/openai-compatible.ts +23 -8
- package/src/web/search/providers/xai.ts +98 -12
- package/src/web/search/types.ts +16 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { getTrustedAgentFile } from "@gajae-code/utils";
|
|
5
|
+
|
|
6
|
+
export const TELEMETRY_SCHEMA_VERSION = 1 as const;
|
|
7
|
+
export const TELEMETRY_INSTALL_ID_FILE = "telemetry-install-id" as const;
|
|
8
|
+
|
|
9
|
+
export const TELEMETRY_EVENT_NAMES = [
|
|
10
|
+
"update_check_started",
|
|
11
|
+
"update_check_completed",
|
|
12
|
+
"update_install_started",
|
|
13
|
+
"update_install_completed",
|
|
14
|
+
"update_install_failed",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type TelemetryEventName = (typeof TELEMETRY_EVENT_NAMES)[number];
|
|
18
|
+
|
|
19
|
+
export interface TelemetryEvent {
|
|
20
|
+
schemaVersion: typeof TELEMETRY_SCHEMA_VERSION;
|
|
21
|
+
event: TelemetryEventName;
|
|
22
|
+
installId: string;
|
|
23
|
+
occurredAt: string;
|
|
24
|
+
channel?: "stable" | "nightly";
|
|
25
|
+
result?: "available" | "up_to_date" | "installed" | "failed" | "skipped";
|
|
26
|
+
installMethod?: "bun" | "npm" | "binary" | "migrate";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type EventInput = {
|
|
30
|
+
event?: unknown;
|
|
31
|
+
installId?: unknown;
|
|
32
|
+
occurredAt?: unknown;
|
|
33
|
+
channel?: unknown;
|
|
34
|
+
result?: unknown;
|
|
35
|
+
installMethod?: unknown;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const EVENT_NAMES = new Set<string>(TELEMETRY_EVENT_NAMES);
|
|
40
|
+
const CHANNELS = new Set(["stable", "nightly"]);
|
|
41
|
+
const RESULTS = new Set(["available", "up_to_date", "installed", "failed", "skipped"]);
|
|
42
|
+
const INSTALL_METHODS = new Set(["bun", "npm", "binary", "migrate"]);
|
|
43
|
+
const FORBIDDEN_KEY = /(?:prompt|argv|path|env|secret|account|model|provider|repo|error|hostname|username|machine|ip)/i;
|
|
44
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
45
|
+
|
|
46
|
+
function hasForbiddenKey(value: unknown, seen = new Set<object>()): boolean {
|
|
47
|
+
if (value === null || typeof value !== "object") return false;
|
|
48
|
+
if (seen.has(value)) return false;
|
|
49
|
+
seen.add(value);
|
|
50
|
+
for (const [key, child] of Object.entries(value)) {
|
|
51
|
+
if (FORBIDDEN_KEY.test(key) || hasForbiddenKey(child, seen)) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function requireString(value: unknown, field: string): string {
|
|
57
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 128) {
|
|
58
|
+
throw new Error(`invalid telemetry ${field}`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Serialize only the versioned telemetry allowlist. Unknown fields are never
|
|
65
|
+
* emitted; forbidden fields anywhere in the input fail closed.
|
|
66
|
+
*/
|
|
67
|
+
export function serializeTelemetryEvent(input: unknown): string {
|
|
68
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
69
|
+
throw new Error("telemetry event must be an object");
|
|
70
|
+
}
|
|
71
|
+
if (hasForbiddenKey(input)) throw new Error("telemetry event contains forbidden data");
|
|
72
|
+
const value = input as EventInput;
|
|
73
|
+
const event = requireString(value.event, "event");
|
|
74
|
+
if (!EVENT_NAMES.has(event)) throw new Error("invalid telemetry event");
|
|
75
|
+
const installId = requireString(value.installId, "installId");
|
|
76
|
+
if (!UUID_V4.test(installId)) throw new Error("invalid telemetry installId");
|
|
77
|
+
const occurredAt = requireString(value.occurredAt, "occurredAt");
|
|
78
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(occurredAt) || Number.isNaN(Date.parse(occurredAt))) {
|
|
79
|
+
throw new Error("invalid telemetry occurredAt");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const output: TelemetryEvent = {
|
|
83
|
+
schemaVersion: TELEMETRY_SCHEMA_VERSION,
|
|
84
|
+
event: event as TelemetryEventName,
|
|
85
|
+
installId,
|
|
86
|
+
occurredAt,
|
|
87
|
+
};
|
|
88
|
+
if (value.channel !== undefined) {
|
|
89
|
+
if (typeof value.channel !== "string" || !CHANNELS.has(value.channel))
|
|
90
|
+
throw new Error("invalid telemetry channel");
|
|
91
|
+
output.channel = value.channel as TelemetryEvent["channel"];
|
|
92
|
+
}
|
|
93
|
+
if (value.result !== undefined) {
|
|
94
|
+
if (typeof value.result !== "string" || !RESULTS.has(value.result)) throw new Error("invalid telemetry result");
|
|
95
|
+
output.result = value.result as TelemetryEvent["result"];
|
|
96
|
+
}
|
|
97
|
+
if (value.installMethod !== undefined) {
|
|
98
|
+
if (typeof value.installMethod !== "string" || !INSTALL_METHODS.has(value.installMethod)) {
|
|
99
|
+
throw new Error("invalid telemetry installMethod");
|
|
100
|
+
}
|
|
101
|
+
output.installMethod = value.installMethod as TelemetryEvent["installMethod"];
|
|
102
|
+
}
|
|
103
|
+
return `${JSON.stringify(output)}\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function writeNewInstallId(filePath: string, installId: string): Promise<void> {
|
|
107
|
+
const handle = await fs.open(filePath, "wx", 0o600);
|
|
108
|
+
try {
|
|
109
|
+
await handle.writeFile(`${installId}\n`, "utf8");
|
|
110
|
+
} finally {
|
|
111
|
+
await handle.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Load or create a random UUIDv4 that is not derived from machine data. */
|
|
116
|
+
export async function getTelemetryInstallId(
|
|
117
|
+
filePath = getTrustedAgentFile(TELEMETRY_INSTALL_ID_FILE),
|
|
118
|
+
): Promise<string> {
|
|
119
|
+
try {
|
|
120
|
+
const existing = (await Bun.file(filePath).text()).trim();
|
|
121
|
+
if (UUID_V4.test(existing)) {
|
|
122
|
+
await fs.chmod(filePath, 0o600);
|
|
123
|
+
return existing;
|
|
124
|
+
}
|
|
125
|
+
throw new Error("telemetry install ID is malformed");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
131
|
+
const generated = randomUUID();
|
|
132
|
+
try {
|
|
133
|
+
await writeNewInstallId(filePath, generated);
|
|
134
|
+
return generated;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
137
|
+
const existing = (await Bun.file(filePath).text()).trim();
|
|
138
|
+
if (!UUID_V4.test(existing)) throw new Error("telemetry install ID is malformed");
|
|
139
|
+
await fs.chmod(filePath, 0o600);
|
|
140
|
+
return existing;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Settings } from "../config/settings";
|
|
2
|
+
import { getTelemetryStatus } from "./control";
|
|
3
|
+
import { getTelemetryInstallId, serializeTelemetryEvent, type TelemetryEvent, type TelemetryEventName } from "./events";
|
|
4
|
+
|
|
5
|
+
export const TELEMETRY_ENDPOINT = "https://telemetry.gajae.dev/v1/events" as const;
|
|
6
|
+
export const TELEMETRY_MAX_IN_FLIGHT = 2 as const;
|
|
7
|
+
export const TELEMETRY_TIMEOUT_MS = 1500 as const;
|
|
8
|
+
export const TELEMETRY_FLUSH_TIMEOUT_MS = 2000 as const;
|
|
9
|
+
|
|
10
|
+
interface TelemetryTransportDependencies {
|
|
11
|
+
fetchImpl?: (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TelemetryDetails = Pick<TelemetryEvent, "channel" | "result" | "installMethod">;
|
|
16
|
+
|
|
17
|
+
let inFlight = 0;
|
|
18
|
+
const pendingTelemetry = new Set<Promise<void>>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Best-effort transport. The caller only schedules work; disabled telemetry,
|
|
22
|
+
* queue saturation, network errors, and non-2xx responses are all ignored.
|
|
23
|
+
*/
|
|
24
|
+
export function sendTelemetryEvent(
|
|
25
|
+
settings: Parameters<typeof getTelemetryStatus>[0],
|
|
26
|
+
event: TelemetryEvent,
|
|
27
|
+
deps: TelemetryTransportDependencies = {},
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
if (!getTelemetryStatus(settings).enabled || inFlight >= TELEMETRY_MAX_IN_FLIGHT) return Promise.resolve();
|
|
30
|
+
let body: string;
|
|
31
|
+
try {
|
|
32
|
+
body = serializeTelemetryEvent(event);
|
|
33
|
+
} catch {
|
|
34
|
+
return Promise.resolve();
|
|
35
|
+
}
|
|
36
|
+
inFlight++;
|
|
37
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
|
|
40
|
+
timeout.unref?.();
|
|
41
|
+
const delivery = fetchImpl(deps.endpoint ?? TELEMETRY_ENDPOINT, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
body,
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
redirect: "error",
|
|
47
|
+
})
|
|
48
|
+
.catch(() => undefined)
|
|
49
|
+
.then(() => undefined)
|
|
50
|
+
.finally(() => {
|
|
51
|
+
clearTimeout(timeout);
|
|
52
|
+
inFlight--;
|
|
53
|
+
});
|
|
54
|
+
pendingTelemetry.add(delivery);
|
|
55
|
+
void delivery.finally(() => pendingTelemetry.delete(delivery));
|
|
56
|
+
return delivery;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function resetTelemetryTransportForTest(): Promise<void> {
|
|
60
|
+
await Promise.allSettled([...pendingTelemetry]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Schedule an event without making telemetry part of the caller's lifecycle. */
|
|
64
|
+
export function recordTelemetryEvent(event: TelemetryEventName, details: TelemetryDetails): Promise<void> {
|
|
65
|
+
const pending = (async () => {
|
|
66
|
+
const settings = await Settings.init();
|
|
67
|
+
if (!getTelemetryStatus(settings).enabled) return;
|
|
68
|
+
const installId = await getTelemetryInstallId();
|
|
69
|
+
await sendTelemetryEvent(settings, {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
event,
|
|
72
|
+
installId,
|
|
73
|
+
occurredAt: new Date().toISOString(),
|
|
74
|
+
...details,
|
|
75
|
+
});
|
|
76
|
+
})().catch(() => undefined);
|
|
77
|
+
pendingTelemetry.add(pending);
|
|
78
|
+
void pending.finally(() => pendingTelemetry.delete(pending));
|
|
79
|
+
return pending;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Wait briefly for accepted events so explicit process exits do not drop them. */
|
|
83
|
+
export async function flushTelemetryEvents(timeoutMs = TELEMETRY_FLUSH_TIMEOUT_MS): Promise<void> {
|
|
84
|
+
const pending = [...pendingTelemetry];
|
|
85
|
+
if (pending.length === 0) return;
|
|
86
|
+
await Promise.race([Promise.allSettled(pending), Bun.sleep(timeoutMs)]);
|
|
87
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { PtySession as NativePtySession, PtyRunResult } from "@gajae-code/n
|
|
|
3
3
|
import {
|
|
4
4
|
type Component,
|
|
5
5
|
extractPrintableText,
|
|
6
|
+
getKeybindings,
|
|
6
7
|
matchesKey,
|
|
7
8
|
padding,
|
|
8
9
|
parseKey,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
truncateToWidth,
|
|
11
12
|
visibleWidth,
|
|
12
13
|
} from "@gajae-code/tui";
|
|
13
|
-
import { sanitizeText } from "@gajae-code/utils";
|
|
14
|
+
import { logger, sanitizeText } from "@gajae-code/utils";
|
|
14
15
|
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
|
15
16
|
import { Settings } from "../config/settings";
|
|
16
17
|
import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
|
|
@@ -115,6 +116,7 @@ class BashInteractiveOverlayComponent implements Component {
|
|
|
115
116
|
#writeOffset = 0;
|
|
116
117
|
#flushResolvers: Array<() => void> = [];
|
|
117
118
|
#writing = false;
|
|
119
|
+
#onFoldKey: () => boolean = () => false;
|
|
118
120
|
|
|
119
121
|
constructor(
|
|
120
122
|
private readonly command: string,
|
|
@@ -131,10 +133,16 @@ class BashInteractiveOverlayComponent implements Component {
|
|
|
131
133
|
});
|
|
132
134
|
}
|
|
133
135
|
|
|
134
|
-
setHandlers(
|
|
136
|
+
setHandlers(
|
|
137
|
+
onInput: (data: string) => void,
|
|
138
|
+
onDismiss: () => void,
|
|
139
|
+
onDispose: () => void,
|
|
140
|
+
onFoldKey: () => boolean,
|
|
141
|
+
): void {
|
|
135
142
|
this.#onInput = onInput;
|
|
136
143
|
this.#onDismiss = onDismiss;
|
|
137
144
|
this.#onDispose = onDispose;
|
|
145
|
+
this.#onFoldKey = onFoldKey;
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
appendOutput(chunk: string): void {
|
|
@@ -199,6 +207,9 @@ class BashInteractiveOverlayComponent implements Component {
|
|
|
199
207
|
}
|
|
200
208
|
|
|
201
209
|
handleInput(data: string): void {
|
|
210
|
+
if (this.#state === "running" && getKeybindings().matches(data, "app.tool.backgroundFold")) {
|
|
211
|
+
if (this.#onFoldKey()) return;
|
|
212
|
+
}
|
|
202
213
|
if (this.#state === "running" && (matchesKey(data, "escape") || matchesKey(data, "esc"))) {
|
|
203
214
|
this.#onDismiss();
|
|
204
215
|
return;
|
|
@@ -293,6 +304,25 @@ class BashInteractiveOverlayComponent implements Component {
|
|
|
293
304
|
}
|
|
294
305
|
}
|
|
295
306
|
|
|
307
|
+
/** Live controls handed to the caller once the PTY run owns its session. */
|
|
308
|
+
export interface InteractivePtyControls {
|
|
309
|
+
/**
|
|
310
|
+
* Detach the overlay and settle the foreground call with `foldResult`, leaving
|
|
311
|
+
* the process running and still writing into the sink. Idempotent.
|
|
312
|
+
*/
|
|
313
|
+
detachObserver: (foldResult: BashInteractiveResult) => "resolved" | "already-settled";
|
|
314
|
+
/**
|
|
315
|
+
* Resolves with the run's real summary when the process actually ends,
|
|
316
|
+
* regardless of whether the foreground was folded. A folded run is delivered
|
|
317
|
+
* from this, so its result can never be silently dropped.
|
|
318
|
+
*/
|
|
319
|
+
terminalCompletion: Promise<BashInteractiveResult>;
|
|
320
|
+
/** Kill the owned process during owner teardown; the observer never calls this. */
|
|
321
|
+
kill: () => void;
|
|
322
|
+
/** Stop forwarding the foreground abort signal after ownership transfers on fold. */
|
|
323
|
+
detachForegroundCancellation: () => void;
|
|
324
|
+
}
|
|
325
|
+
|
|
296
326
|
export async function runInteractiveBashPty(
|
|
297
327
|
ui: NonNullable<AgentToolContext["ui"]>,
|
|
298
328
|
options: {
|
|
@@ -307,6 +337,11 @@ export async function runInteractiveBashPty(
|
|
|
307
337
|
spillThreshold?: number;
|
|
308
338
|
headBytes?: number;
|
|
309
339
|
settings?: Settings;
|
|
340
|
+
/** Receives live controls once the session is running; used to register a fold. */
|
|
341
|
+
onControls?: (controls: InteractivePtyControls) => void;
|
|
342
|
+
onFoldKey?: () => boolean;
|
|
343
|
+
onDismiss?: () => void;
|
|
344
|
+
onOutput?: (chunk: string) => void;
|
|
310
345
|
},
|
|
311
346
|
): Promise<BashInteractiveResult> {
|
|
312
347
|
const settings = options.settings ?? (await Settings.init());
|
|
@@ -322,89 +357,151 @@ export async function runInteractiveBashPty(
|
|
|
322
357
|
const { default: xterm } = await import("@xterm/headless");
|
|
323
358
|
const XtermTerminal = xterm.Terminal;
|
|
324
359
|
const PtySession = await ptySessionNative();
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
360
|
+
|
|
361
|
+
// Ownership inversion: this runner owns the session and the sink from t0,
|
|
362
|
+
// started OUTSIDE ui.custom. The overlay is only an observer view, so
|
|
363
|
+
// dismissing it — or never creating it — can never kill the process.
|
|
364
|
+
// Seed the real host geometry so the first frame renders at the user's size;
|
|
365
|
+
// the overlay keeps resizing the session as it lays out. Fallbacks apply only
|
|
366
|
+
// when the host exposes no terminal (tests, pipes).
|
|
367
|
+
const initialPtySize = {
|
|
368
|
+
cols: Math.max(20, (process.stdout.columns ?? 80) - 2),
|
|
369
|
+
rows: Math.max(5, (process.stdout.rows ?? 24) - 4),
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const session = new PtySession();
|
|
373
|
+
const processAbortController = new AbortController();
|
|
374
|
+
const forwardForegroundAbort = () => processAbortController.abort();
|
|
375
|
+
if (options.signal?.aborted) processAbortController.abort();
|
|
376
|
+
else options.signal?.addEventListener("abort", forwardForegroundAbort, { once: true });
|
|
377
|
+
let observer: BashInteractiveOverlayComponent | undefined;
|
|
378
|
+
let settleForeground: ((result: BashInteractiveResult) => void) | undefined;
|
|
379
|
+
let settled = false;
|
|
380
|
+
const foreground = Promise.withResolvers<BashInteractiveResult>();
|
|
381
|
+
const terminal = Promise.withResolvers<BashInteractiveResult>();
|
|
382
|
+
|
|
383
|
+
const settle = (result: BashInteractiveResult): "resolved" | "already-settled" => {
|
|
384
|
+
if (settled) return "already-settled";
|
|
385
|
+
settled = true;
|
|
386
|
+
// Prefer ui.custom's own `done` so the overlay tears down through its normal
|
|
387
|
+
// path; fall back to the outer promise when no overlay was ever attached.
|
|
388
|
+
if (settleForeground) settleForeground(result);
|
|
389
|
+
else foreground.resolve(result);
|
|
390
|
+
return "resolved";
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
let finished = false;
|
|
394
|
+
const finalize = (run: PtyRunResult): void => {
|
|
395
|
+
if (finished) return;
|
|
396
|
+
finished = true;
|
|
397
|
+
observer?.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
|
|
398
|
+
void (async () => {
|
|
399
|
+
await observer?.flushOutput();
|
|
400
|
+
const summary = await sink.dump();
|
|
401
|
+
const outcome: BashInteractiveResult = {
|
|
402
|
+
exitCode: run.exitCode,
|
|
403
|
+
cancelled: run.cancelled,
|
|
404
|
+
timedOut: run.timedOut,
|
|
405
|
+
...summary,
|
|
351
406
|
};
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
},
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const normalizedChunk = normalizeCaptureChunk(chunk);
|
|
396
|
-
sink.push(normalizedChunk);
|
|
397
|
-
tui.requestRender();
|
|
398
|
-
},
|
|
399
|
-
)
|
|
400
|
-
.then(finalize)
|
|
401
|
-
.catch(error => {
|
|
402
|
-
sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
403
|
-
finalize({ exitCode: undefined, cancelled: false, timedOut: false });
|
|
404
|
-
});
|
|
405
|
-
return component;
|
|
407
|
+
options.signal?.removeEventListener("abort", forwardForegroundAbort);
|
|
408
|
+
// Publish the real outcome BEFORE settling: a folded run's foreground is
|
|
409
|
+
// already gone, and this is the only path that can deliver its result.
|
|
410
|
+
terminal.resolve(outcome);
|
|
411
|
+
settle(outcome);
|
|
412
|
+
})();
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// The original deadline rides on the run itself, so a folded command expires
|
|
416
|
+
// exactly when it would have in the foreground.
|
|
417
|
+
void session
|
|
418
|
+
.start(
|
|
419
|
+
{
|
|
420
|
+
command: options.command,
|
|
421
|
+
cwd: options.cwd,
|
|
422
|
+
timeoutMs: options.timeoutMs,
|
|
423
|
+
env: { ...NON_INTERACTIVE_ENV, ...options.env },
|
|
424
|
+
signal: processAbortController.signal,
|
|
425
|
+
cols: initialPtySize.cols,
|
|
426
|
+
rows: initialPtySize.rows,
|
|
427
|
+
shell: resolvedShell,
|
|
428
|
+
},
|
|
429
|
+
(err, chunk) => {
|
|
430
|
+
if (finished || err || !chunk) return;
|
|
431
|
+
// The sink is fed unconditionally: output stays continuous across a
|
|
432
|
+
// fold, when no overlay exists, and after the overlay is disposed.
|
|
433
|
+
const normalizedChunk = normalizeCaptureChunk(chunk);
|
|
434
|
+
sink.push(normalizedChunk);
|
|
435
|
+
options.onOutput?.(normalizedChunk);
|
|
436
|
+
observer?.appendOutput(chunk);
|
|
437
|
+
},
|
|
438
|
+
)
|
|
439
|
+
.then(finalize)
|
|
440
|
+
.catch(error => {
|
|
441
|
+
sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
442
|
+
finalize({ exitCode: undefined, cancelled: false, timedOut: false });
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
options.onControls?.({
|
|
446
|
+
terminalCompletion: terminal.promise,
|
|
447
|
+
kill: () => session.kill(),
|
|
448
|
+
detachForegroundCancellation: () => {
|
|
449
|
+
options.signal?.removeEventListener("abort", forwardForegroundAbort);
|
|
406
450
|
},
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
451
|
+
detachObserver: (foldResult: BashInteractiveResult) => {
|
|
452
|
+
const outcome = settle(foldResult);
|
|
453
|
+
if (outcome === "resolved") observer = undefined;
|
|
454
|
+
return outcome;
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Folded (or already finished) before any view existed: there is nothing to
|
|
459
|
+
// attach an observer to, so never await an overlay that will never be settled.
|
|
460
|
+
if (settled) return await foreground.promise;
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
const overlayResult = await Promise.race([
|
|
464
|
+
foreground.promise,
|
|
465
|
+
ui.custom<BashInteractiveResult>(
|
|
466
|
+
(tui, uiTheme, _keybindings, done) => {
|
|
467
|
+
settleForeground = done;
|
|
468
|
+
const component = new BashInteractiveOverlayComponent(
|
|
469
|
+
options.command,
|
|
470
|
+
uiTheme,
|
|
471
|
+
() => tui.terminal.rows,
|
|
472
|
+
XtermTerminal,
|
|
473
|
+
);
|
|
474
|
+
component.setSession(session);
|
|
475
|
+
// Observer view only: stdin still reaches the process while attached,
|
|
476
|
+
// Escape explicitly force-kills, while disposal never kills the work.
|
|
477
|
+
component.setHandlers(
|
|
478
|
+
data => {
|
|
479
|
+
try {
|
|
480
|
+
session.write(data);
|
|
481
|
+
} catch {
|
|
482
|
+
// ignore writes after the command exits
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
options.onDismiss ?? (() => session.kill()),
|
|
486
|
+
() => {},
|
|
487
|
+
options.onFoldKey ?? (() => false),
|
|
488
|
+
);
|
|
489
|
+
observer = component;
|
|
490
|
+
return component;
|
|
491
|
+
},
|
|
492
|
+
{ overlay: true },
|
|
493
|
+
),
|
|
494
|
+
]);
|
|
495
|
+
return overlayResult;
|
|
496
|
+
} catch (error) {
|
|
497
|
+
// Overlay creation or view init failed. The process is already running and
|
|
498
|
+
// owned here, so surface the failure without orphaning it: the run keeps
|
|
499
|
+
// writing to the sink and settles the foreground on its own.
|
|
500
|
+
logger.warn("Interactive PTY overlay unavailable; continuing without the view", {
|
|
501
|
+
error: error instanceof Error ? error.message : String(error),
|
|
502
|
+
});
|
|
503
|
+
observer = undefined;
|
|
504
|
+
settleForeground = undefined;
|
|
505
|
+
return await foreground.promise;
|
|
506
|
+
}
|
|
410
507
|
}
|