@gajae-code/coding-agent 0.5.1 → 0.5.2
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 +17 -0
- package/README.md +1 -1
- package/dist/types/cli/setup-cli.d.ts +8 -1
- package/dist/types/commands/setup.d.ts +7 -0
- package/dist/types/config/file-lock.d.ts +24 -2
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +62 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/theme/defaults/index.d.ts +302 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/session/history-storage.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +10 -1
- package/dist/types/setup/credential-import.d.ts +79 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/render.d.ts +1 -1
- package/dist/types/tools/subagent-render.d.ts +7 -1
- package/dist/types/tools/subagent.d.ts +21 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +4 -4
- package/dist/types/web/search/provider.d.ts +16 -20
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +14 -2
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/cli/args.ts +2 -0
- package/src/cli/fast-help.ts +2 -0
- package/src/cli/setup-cli.ts +138 -3
- package/src/commands/setup.ts +5 -1
- package/src/commands/ultragoal.ts +3 -1
- package/src/config/file-lock-gc.ts +14 -2
- package/src/config/file-lock.ts +54 -12
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +15 -15
- package/src/config/model-registry.ts +21 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +62 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
- package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
- package/src/gjc-runtime/launch-tmux.ts +3 -4
- package/src/gjc-runtime/ralplan-runtime.ts +174 -12
- package/src/gjc-runtime/state-runtime.ts +2 -1
- package/src/gjc-runtime/state-writer.ts +254 -7
- package/src/gjc-runtime/tmux-gc.ts +2 -1
- package/src/gjc-runtime/ultragoal-guard.ts +155 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/harness-control-plane/owner.ts +3 -2
- package/src/harness-control-plane/rpc-adapter.ts +1 -1
- package/src/hooks/skill-state.ts +121 -2
- package/src/internal-urls/docs-index.generated.ts +13 -9
- package/src/lsp/defaults.json +1 -0
- package/src/main.ts +14 -4
- package/src/modes/acp/acp-agent.ts +4 -2
- package/src/modes/bridge/bridge-mode.ts +2 -1
- package/src/modes/components/history-search.ts +5 -2
- package/src/modes/components/model-selector.ts +26 -0
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/controllers/selector-controller.ts +80 -1
- package/src/modes/interactive-mode.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +132 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/modes/theme/defaults/claude-code.json +100 -0
- package/src/modes/theme/defaults/codex.json +100 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/opencode.json +102 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/prompts/agents/executor.md +5 -2
- package/src/sdk.ts +12 -1
- package/src/session/agent-session.ts +22 -11
- package/src/session/history-storage.ts +32 -11
- package/src/session/session-manager.ts +70 -18
- package/src/setup/credential-import.ts +429 -0
- package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
- package/src/task/executor.ts +7 -1
- package/src/task/render.ts +18 -7
- package/src/tools/ask.ts +4 -2
- package/src/tools/cron.ts +1 -1
- package/src/tools/subagent-render.ts +119 -29
- package/src/tools/subagent.ts +147 -7
- package/src/tools/ultragoal-ask-guard.ts +39 -0
- package/src/web/search/index.ts +25 -25
- package/src/web/search/provider.ts +178 -87
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/openai-compatible.ts +151 -0
- package/src/web/search/types.ts +47 -22
package/src/lsp/defaults.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -977,10 +977,20 @@ export async function runRootCommand(
|
|
|
977
977
|
}
|
|
978
978
|
|
|
979
979
|
if (mode === "rpc" || mode === "rpc-ui") {
|
|
980
|
-
const { runRpcMode } = await import("./modes/rpc/rpc-mode");
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
980
|
+
const { RpcListenRefusedError, runRpcMode } = await import("./modes/rpc/rpc-mode");
|
|
981
|
+
try {
|
|
982
|
+
await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, {
|
|
983
|
+
listen: parsedArgs.rpcListen,
|
|
984
|
+
});
|
|
985
|
+
} catch (error) {
|
|
986
|
+
if (!(error instanceof RpcListenRefusedError)) throw error;
|
|
987
|
+
logger.setTransports({ console: true, file: true });
|
|
988
|
+
logger.error(error.message);
|
|
989
|
+
await session.dispose();
|
|
990
|
+
stopThemeWatcher();
|
|
991
|
+
await postmortem.quit(1);
|
|
992
|
+
process.exit(1);
|
|
993
|
+
}
|
|
984
994
|
} else if (mode === "bridge") {
|
|
985
995
|
const { runBridgeMode } = await import("./modes/bridge/bridge-mode");
|
|
986
996
|
await runBridgeMode(session, setToolUIContext);
|
|
@@ -43,7 +43,8 @@ import {
|
|
|
43
43
|
type Usage,
|
|
44
44
|
} from "@agentclientprotocol/sdk";
|
|
45
45
|
import type { AssistantMessage, Model } from "@gajae-code/ai";
|
|
46
|
-
import { logger
|
|
46
|
+
import { logger } from "@gajae-code/utils";
|
|
47
|
+
import packageJson from "../../../package.json" with { type: "json" };
|
|
47
48
|
import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
|
|
48
49
|
import { Settings } from "../../config/settings";
|
|
49
50
|
import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
|
|
@@ -98,6 +99,7 @@ const SESSION_PAGE_SIZE = 50;
|
|
|
98
99
|
* wait past this guard without hard-coding the literal.
|
|
99
100
|
*/
|
|
100
101
|
export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
|
|
102
|
+
const CODING_AGENT_VERSION: string = packageJson.version;
|
|
101
103
|
const ACP_CANCEL_CLEANUP_TIMEOUT_MS = 5_000;
|
|
102
104
|
const ACP_ASYNC_DELIVERY_DRAIN_TIMEOUT_MS = 250;
|
|
103
105
|
const ACP_ASYNC_DELIVERY_DRAIN_MAX_PASSES = 3;
|
|
@@ -414,7 +416,7 @@ export class AcpAgent implements Agent {
|
|
|
414
416
|
agentInfo: {
|
|
415
417
|
name: "gajae-code",
|
|
416
418
|
title: "Gajae Code",
|
|
417
|
-
version:
|
|
419
|
+
version: CODING_AGENT_VERSION,
|
|
418
420
|
},
|
|
419
421
|
authMethods,
|
|
420
422
|
agentCapabilities: {
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import { UiRequestBroker } from "../shared/agent-wire/ui-request-broker";
|
|
30
30
|
import type { BridgeUiResult } from "../shared/agent-wire/ui-result";
|
|
31
31
|
import { defaultAuditPath, UnattendedAuditLog } from "../shared/agent-wire/unattended-audit";
|
|
32
|
-
import { UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
|
|
32
|
+
import { modelSupportsTokenCostMetrics, UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
|
|
33
33
|
import { FileGateStore } from "../shared/agent-wire/workflow-gate-broker";
|
|
34
34
|
import { assertSafeBridgeBind, isBridgeTokenAuthorized } from "./auth";
|
|
35
35
|
import { type BridgePermissionRequestPayload, createBridgeClientBridge } from "./bridge-client-bridge";
|
|
@@ -599,6 +599,7 @@ export async function runBridgeMode(
|
|
|
599
599
|
emitFrame: gate => eventStream.publish(toBridgeWorkflowGateFrame(gate, sequencer)),
|
|
600
600
|
store: gateStore,
|
|
601
601
|
audit: recordAudit,
|
|
602
|
+
providerSupportsTokenCostMetrics: modelSupportsTokenCostMetrics(session.model),
|
|
602
603
|
getUsageSnapshot: () => {
|
|
603
604
|
const stats = session.getSessionStats();
|
|
604
605
|
return { tokens: stats.tokens.total, costUsd: stats.cost };
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
truncateToWidth,
|
|
11
11
|
visibleWidth,
|
|
12
12
|
} from "@gajae-code/tui";
|
|
13
|
+
import { getProjectDir } from "@gajae-code/utils";
|
|
13
14
|
import { theme } from "../../modes/theme/theme";
|
|
14
15
|
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
|
15
16
|
import type { HistoryEntry, HistoryStorage } from "../../session/history-storage";
|
|
@@ -72,6 +73,7 @@ class HistoryResultsList implements Component {
|
|
|
72
73
|
|
|
73
74
|
export class HistorySearchComponent extends Container {
|
|
74
75
|
#historyStorage: HistoryStorage;
|
|
76
|
+
#cwd: string;
|
|
75
77
|
#searchInput: Input;
|
|
76
78
|
#results: HistoryEntry[] = [];
|
|
77
79
|
#selectedIndex = 0;
|
|
@@ -83,6 +85,7 @@ export class HistorySearchComponent extends Container {
|
|
|
83
85
|
constructor(historyStorage: HistoryStorage, onSelect: (prompt: string) => void, onCancel: () => void) {
|
|
84
86
|
super();
|
|
85
87
|
this.#historyStorage = historyStorage;
|
|
88
|
+
this.#cwd = getProjectDir();
|
|
86
89
|
this.#onSelect = onSelect;
|
|
87
90
|
this.#onCancel = onCancel;
|
|
88
91
|
|
|
@@ -150,8 +153,8 @@ export class HistorySearchComponent extends Container {
|
|
|
150
153
|
#updateResults(): void {
|
|
151
154
|
const query = this.#searchInput.getValue().trim();
|
|
152
155
|
this.#results = query
|
|
153
|
-
? this.#historyStorage.search(query, this.#resultLimit)
|
|
154
|
-
: this.#historyStorage.getRecent(this.#resultLimit);
|
|
156
|
+
? this.#historyStorage.search(query, this.#resultLimit, this.#cwd)
|
|
157
|
+
: this.#historyStorage.getRecent(this.#resultLimit, this.#cwd);
|
|
155
158
|
this.#selectedIndex = 0;
|
|
156
159
|
this.#resultsList.setResults(this.#results, this.#selectedIndex);
|
|
157
160
|
}
|
|
@@ -154,6 +154,20 @@ interface PresetBrowseRow {
|
|
|
154
154
|
|
|
155
155
|
type PresetLandingRow = PresetGroupRow | PresetProfileRow | PresetBrowseRow;
|
|
156
156
|
|
|
157
|
+
// Stable logical identity for a preset landing row, independent of its current
|
|
158
|
+
// list position. Used to relocate the cursor after the expanded group changes so
|
|
159
|
+
// navigation does not silently overshoot the destination group header/profiles.
|
|
160
|
+
function presetRowIdentity(row: PresetLandingRow): string {
|
|
161
|
+
switch (row.kind) {
|
|
162
|
+
case "group":
|
|
163
|
+
return `group:${row.groupId}`;
|
|
164
|
+
case "profile":
|
|
165
|
+
return `profile:${row.groupId}:${row.profile.name}`;
|
|
166
|
+
case "browse":
|
|
167
|
+
return "browse";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
const PROFILE_ROLE_PREVIEW_ORDER: GjcModelAssignmentTargetId[] = [
|
|
158
172
|
"default",
|
|
159
173
|
"executor",
|
|
@@ -739,6 +753,18 @@ export class ModelSelectorComponent extends Container {
|
|
|
739
753
|
if (selected?.kind === "group") this.#expandedPresetProviderId = selected.groupId;
|
|
740
754
|
if (selected?.kind === "profile") this.#expandedPresetProviderId = selected.groupId;
|
|
741
755
|
const rows = this.#getPresetRows();
|
|
756
|
+
// Expanding/collapsing a group shifts row positions. Relocate the cursor by
|
|
757
|
+
// the selected row's logical identity so crossing a provider group boundary
|
|
758
|
+
// keeps it on the same logical row instead of overshooting into the
|
|
759
|
+
// destination group's profiles (or off the end of the list).
|
|
760
|
+
if (selected) {
|
|
761
|
+
const targetIdentity = presetRowIdentity(selected);
|
|
762
|
+
const relocated = rows.findIndex(row => presetRowIdentity(row) === targetIdentity);
|
|
763
|
+
if (relocated >= 0) {
|
|
764
|
+
this.#presetCursor = relocated;
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
742
768
|
this.#presetCursor = Math.min(this.#presetCursor, Math.max(0, rows.length - 1));
|
|
743
769
|
}
|
|
744
770
|
|
|
@@ -4,7 +4,7 @@ import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
|
|
|
4
4
|
import { formatModelOnboardingGuidance } from "../../setup/model-onboarding-guidance";
|
|
5
5
|
import { DynamicBorder } from "./dynamic-border";
|
|
6
6
|
|
|
7
|
-
export type ProviderOnboardingAction = "custom-provider-wizard" | "oauth-login" | "api-guide";
|
|
7
|
+
export type ProviderOnboardingAction = "custom-provider-wizard" | "oauth-login" | "import-credentials" | "api-guide";
|
|
8
8
|
|
|
9
9
|
interface ProviderOnboardingOption {
|
|
10
10
|
label: string;
|
|
@@ -28,6 +28,11 @@ const PROVIDER_ONBOARDING_OPTIONS: ProviderOnboardingOption[] = [
|
|
|
28
28
|
description: "Show the /provider add and gjc setup provider commands.",
|
|
29
29
|
action: "api-guide",
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
label: "Import existing credentials",
|
|
33
|
+
description: "Detect and import Claude Code / Codex CLI logins already on this machine.",
|
|
34
|
+
action: "import-credentials",
|
|
35
|
+
},
|
|
31
36
|
];
|
|
32
37
|
|
|
33
38
|
export class ProviderOnboardingSelectorComponent extends Container {
|
|
@@ -32,13 +32,20 @@ import {
|
|
|
32
32
|
import type { InteractiveModeContext } from "../../modes/types";
|
|
33
33
|
import { type SessionInfo, SessionManager } from "../../session/session-manager";
|
|
34
34
|
import { FileSessionStorage } from "../../session/session-storage";
|
|
35
|
+
import { discoverExternalCredentials, formatDiscoverySummary, importCredentials } from "../../setup/credential-import";
|
|
35
36
|
import {
|
|
36
37
|
MODEL_ONBOARDING_API_PROVIDER_COMMAND,
|
|
37
38
|
MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND,
|
|
38
39
|
MODEL_ONBOARDING_SETUP_COMMAND,
|
|
39
40
|
} from "../../setup/model-onboarding-guidance";
|
|
40
41
|
import { addApiCompatibleProvider, formatProviderSetupResult } from "../../setup/provider-onboarding";
|
|
41
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
isConfigurableSearchProviderId,
|
|
44
|
+
isSearchProviderPreference,
|
|
45
|
+
setPreferredImageProvider,
|
|
46
|
+
setPreferredSearchProvider,
|
|
47
|
+
setSearchFallbackProviders,
|
|
48
|
+
} from "../../tools";
|
|
42
49
|
import { setSessionTerminalTitle } from "../../utils/title-generator";
|
|
43
50
|
import { AgentDashboard } from "../components/agent-dashboard";
|
|
44
51
|
import { AssistantMessageComponent } from "../components/assistant-message";
|
|
@@ -128,6 +135,8 @@ export class SelectorController {
|
|
|
128
135
|
this.showCustomProviderWizard();
|
|
129
136
|
} else if (action === "oauth-login") {
|
|
130
137
|
void this.showOAuthSelector("login");
|
|
138
|
+
} else if (action === "import-credentials") {
|
|
139
|
+
void this.#handleCredentialImport();
|
|
131
140
|
} else {
|
|
132
141
|
this.ctx.showStatus(formatProviderOnboardingCommandGuide());
|
|
133
142
|
}
|
|
@@ -141,6 +150,69 @@ export class SelectorController {
|
|
|
141
150
|
});
|
|
142
151
|
}
|
|
143
152
|
|
|
153
|
+
async #handleCredentialImport(): Promise<void> {
|
|
154
|
+
this.ctx.showStatus("Scanning for existing Claude Code / Codex CLI credentials…");
|
|
155
|
+
const result = await discoverExternalCredentials();
|
|
156
|
+
const summaryLines = formatDiscoverySummary(result);
|
|
157
|
+
|
|
158
|
+
if (result.importable.length === 0) {
|
|
159
|
+
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
160
|
+
for (const line of summaryLines) {
|
|
161
|
+
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", line), 1, 0));
|
|
162
|
+
}
|
|
163
|
+
this.ctx.chatContainer.addChild(
|
|
164
|
+
new Text(
|
|
165
|
+
theme.fg(
|
|
166
|
+
"warning",
|
|
167
|
+
"No importable Claude/Codex credentials found. Use /login or add a custom provider.",
|
|
168
|
+
),
|
|
169
|
+
1,
|
|
170
|
+
0,
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
this.ctx.ui.requestRender();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const confirmed = await this.ctx.showHookConfirm(
|
|
178
|
+
`Import ${result.importable.length} credential(s)?`,
|
|
179
|
+
summaryLines.join("\n"),
|
|
180
|
+
);
|
|
181
|
+
if (!confirmed) {
|
|
182
|
+
this.ctx.showStatus("Credential import cancelled.");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const summary = await importCredentials(result.importable, (provider, credential) =>
|
|
187
|
+
this.ctx.session.modelRegistry.authStorage.upsertCredential(provider, credential),
|
|
188
|
+
);
|
|
189
|
+
await this.ctx.session.modelRegistry.refresh();
|
|
190
|
+
|
|
191
|
+
this.ctx.chatContainer.addChild(new Spacer(1));
|
|
192
|
+
for (const credential of summary.imported) {
|
|
193
|
+
this.ctx.chatContainer.addChild(
|
|
194
|
+
new Text(
|
|
195
|
+
theme.fg("success", `${theme.status.success} Imported ${credential.provider} (${credential.source})`),
|
|
196
|
+
1,
|
|
197
|
+
0,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
for (const failure of summary.failed) {
|
|
202
|
+
this.ctx.chatContainer.addChild(
|
|
203
|
+
new Text(
|
|
204
|
+
theme.fg("error", `${theme.status.error} Failed ${failure.credential.provider}: ${failure.error}`),
|
|
205
|
+
1,
|
|
206
|
+
0,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (summary.imported.length > 0) {
|
|
211
|
+
this.ctx.chatContainer.addChild(new Text(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), 1, 0));
|
|
212
|
+
}
|
|
213
|
+
this.ctx.ui.requestRender();
|
|
214
|
+
}
|
|
215
|
+
|
|
144
216
|
showCustomProviderWizard(): void {
|
|
145
217
|
this.showSelector(done => {
|
|
146
218
|
let wizard: CustomProviderWizardComponent;
|
|
@@ -507,6 +579,13 @@ export class SelectorController {
|
|
|
507
579
|
setPreferredSearchProvider(value);
|
|
508
580
|
}
|
|
509
581
|
break;
|
|
582
|
+
case "web_search.fallback":
|
|
583
|
+
if (Array.isArray(value)) {
|
|
584
|
+
setSearchFallbackProviders(
|
|
585
|
+
value.filter(item => typeof item === "string" && isConfigurableSearchProviderId(item)),
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
break;
|
|
510
589
|
case "providers.image":
|
|
511
590
|
if (
|
|
512
591
|
value === "auto" ||
|
|
@@ -283,7 +283,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
283
283
|
}
|
|
284
284
|
autoCompactionEscapeHandler?: () => void;
|
|
285
285
|
retryEscapeHandler?: () => void;
|
|
286
|
-
retryCountdownTimer?:
|
|
286
|
+
retryCountdownTimer?: NodeJS.Timeout;
|
|
287
287
|
unsubscribe?: () => void;
|
|
288
288
|
onInputCallback?: (input: SubmittedUserInput) => void;
|
|
289
289
|
optimisticUserMessageSignature: string | undefined = undefined;
|
|
@@ -709,6 +709,16 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
709
709
|
if (this.#pendingSubmittedInput) return;
|
|
710
710
|
if (this.editor.getText().trim().length > 0) return;
|
|
711
711
|
if ((this.pendingImages?.length ?? 0) > 0) return;
|
|
712
|
+
// Never fire an autonomous continuation prompt() while the session is
|
|
713
|
+
// busy. A wedged/orphaned subagent turn can leave isStreaming stuck true;
|
|
714
|
+
// firing prompt() here throws AgentBusyError, which submitInteractiveInput
|
|
715
|
+
// surfaces as a red "Error: Agent is already processing…" and then loops
|
|
716
|
+
// back to getUserInput(), re-arming this timer — an infinite error spam.
|
|
717
|
+
// Re-arm and only fire once the session returns to idle.
|
|
718
|
+
if (this.session.isStreaming || this.session.isCompacting) {
|
|
719
|
+
this.#scheduleGoalContinuation();
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
712
722
|
const latestState = this.session.getGoalModeState();
|
|
713
723
|
if (!latestState?.enabled || latestState.goal.status !== "active") return;
|
|
714
724
|
this.#goalContinuationTurnInFlight = true;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import * as fs from "node:fs/promises";
|
|
15
15
|
import * as path from "node:path";
|
|
16
|
-
import { $pickenv, readLines, Snowflake } from "@gajae-code/utils";
|
|
16
|
+
import { $pickenv, logger, readLines, Snowflake } from "@gajae-code/utils";
|
|
17
17
|
import type {
|
|
18
18
|
ExtensionUIContext,
|
|
19
19
|
ExtensionUIDialogOptions,
|
|
@@ -27,7 +27,7 @@ import { AgentWireFrameSequencer, toAgentWireEventFrame } from "../shared/agent-
|
|
|
27
27
|
import { rpcError as error } from "../shared/agent-wire/responses";
|
|
28
28
|
import { registerRpcSession, unregisterRpcSession } from "../shared/agent-wire/session-registry";
|
|
29
29
|
import { defaultAuditPath, UnattendedAuditLog } from "../shared/agent-wire/unattended-audit";
|
|
30
|
-
import { UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
|
|
30
|
+
import { modelSupportsTokenCostMetrics, UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
|
|
31
31
|
import { FileGateStore } from "../shared/agent-wire/workflow-gate-broker";
|
|
32
32
|
import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools";
|
|
33
33
|
import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris";
|
|
@@ -82,6 +82,82 @@ export function shouldEmitRpcTitlesForTest(): boolean {
|
|
|
82
82
|
|
|
83
83
|
const shouldEmitRpcTitles = shouldEmitRpcTitlesForTest;
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Cancellation commands bypass the ordered serial chain because they must
|
|
87
|
+
* interrupt in-flight work — they cannot wait behind the very command they are
|
|
88
|
+
* meant to abort.
|
|
89
|
+
*/
|
|
90
|
+
export const RPC_CANCELLATION_COMMANDS: ReadonlySet<RpcCommand["type"]> = new Set<RpcCommand["type"]>([
|
|
91
|
+
"abort",
|
|
92
|
+
"abort_bash",
|
|
93
|
+
"abort_retry",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Safe read-only commands that bypass the ordered serial chain so they never
|
|
98
|
+
* head-of-line-block behind a long-running ordered command like
|
|
99
|
+
* `bash`/`compact`/`handoff`/`login` (#606, issue 13 — the partial fix only
|
|
100
|
+
* fast-laned cancellation).
|
|
101
|
+
*
|
|
102
|
+
* Every command listed here has a dispatch handler that is **fully synchronous
|
|
103
|
+
* and side-effect-free**: on the single-threaded event loop it runs to
|
|
104
|
+
* completion between the await points of any in-flight ordered command, reading
|
|
105
|
+
* live state without mutating it. Because such a read performs no causal write,
|
|
106
|
+
* jumping ahead of an earlier *queued* ordered command is observably harmless —
|
|
107
|
+
* there is no state change to reorder. Read payloads are additionally
|
|
108
|
+
* snapshotted inside the handler (e.g. `get_messages` returns a shallow copy of
|
|
109
|
+
* `session.messages`) so a fast-lane read can never serialize a half-mutated
|
|
110
|
+
* array that an ordered turn/compaction is rewriting in place.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately excluded (kept ordered): every async/long command and every
|
|
113
|
+
* mutating command. In particular the control-flag setters (`set_thinking_level`,
|
|
114
|
+
* `cycle_thinking_level`, `set_steering_mode`, `set_follow_up_mode`,
|
|
115
|
+
* `set_interrupt_mode`, `set_auto_compaction`, `set_auto_retry`) stay ordered.
|
|
116
|
+
* Their handlers are synchronous, so fast-laning one ahead of an already-queued
|
|
117
|
+
* `prompt`/`bash` would apply the new mode *before* that earlier command runs —
|
|
118
|
+
* the earlier command would then observe the later setter's value, a
|
|
119
|
+
* causal-order (arrival-order) regression. Mutations therefore stay on the
|
|
120
|
+
* chain, and new command types default to ordered (fail-safe).
|
|
121
|
+
*/
|
|
122
|
+
export const RPC_SAFE_READ_CONTROL_COMMANDS: ReadonlySet<RpcCommand["type"]> = new Set<RpcCommand["type"]>([
|
|
123
|
+
// Pure synchronous reads — snapshot live state at processing time, never mutate.
|
|
124
|
+
"get_state",
|
|
125
|
+
"get_session_stats",
|
|
126
|
+
"get_available_models",
|
|
127
|
+
"get_branch_messages",
|
|
128
|
+
"get_last_assistant_text",
|
|
129
|
+
"get_messages",
|
|
130
|
+
"get_login_providers",
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
/** True when a command may bypass the ordered serial chain and run immediately. */
|
|
134
|
+
export function isFastLaneRpcCommand(type: RpcCommand["type"]): boolean {
|
|
135
|
+
return RPC_CANCELLATION_COMMANDS.has(type) || RPC_SAFE_READ_CONTROL_COMMANDS.has(type);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Schedules inbound RPC commands: fast-lane commands run immediately while
|
|
140
|
+
* everything else runs through a serial chain so causal order is preserved. The
|
|
141
|
+
* read loop never blocks, which is what lets a fast-lane command reach a
|
|
142
|
+
* long-running ordered command instead of being head-of-line-blocked behind it.
|
|
143
|
+
*/
|
|
144
|
+
export function createRpcCommandScheduler(
|
|
145
|
+
run: (command: RpcCommand) => Promise<void>,
|
|
146
|
+
track: (task: Promise<void>) => void,
|
|
147
|
+
): { dispatch: (command: RpcCommand) => void } {
|
|
148
|
+
let orderedChain: Promise<void> = Promise.resolve();
|
|
149
|
+
return {
|
|
150
|
+
dispatch(command: RpcCommand): void {
|
|
151
|
+
if (isFastLaneRpcCommand(command.type)) {
|
|
152
|
+
track(run(command));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
orderedChain = orderedChain.then(() => run(command));
|
|
156
|
+
track(orderedChain);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
85
161
|
function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "exceeded" | "aborted" | "info" {
|
|
86
162
|
if (event.includes("denied")) return "denied";
|
|
87
163
|
if (event.includes("exceeded")) return "exceeded";
|
|
@@ -91,6 +167,43 @@ function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "e
|
|
|
91
167
|
return "info";
|
|
92
168
|
}
|
|
93
169
|
|
|
170
|
+
export class RpcListenRefusedError extends Error {
|
|
171
|
+
constructor(socketPath: string) {
|
|
172
|
+
super(
|
|
173
|
+
`RPC --listen refused: a live server is already listening on ${socketPath}. ` +
|
|
174
|
+
"Stop it first or choose a different --listen path.",
|
|
175
|
+
);
|
|
176
|
+
this.name = "RpcListenRefusedError";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Probe whether a unix-domain socket path has a live server accepting
|
|
182
|
+
* connections. Returns `true` when a connection succeeds (a previous owner is
|
|
183
|
+
* still alive), and returns `false` only for known missing/stale endpoints
|
|
184
|
+
* (ENOENT / ECONNREFUSED). Unexpected probe failures fail closed as "alive" so
|
|
185
|
+
* `--listen` startup refuses to unlink a path it could not safely classify.
|
|
186
|
+
*/
|
|
187
|
+
export async function isUnixSocketAlive(socketPath: string): Promise<boolean> {
|
|
188
|
+
try {
|
|
189
|
+
const socket = await Bun.connect({
|
|
190
|
+
unix: socketPath,
|
|
191
|
+
socket: { data() {}, open() {}, error() {}, close() {} },
|
|
192
|
+
});
|
|
193
|
+
socket.end();
|
|
194
|
+
return true;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const code = err && typeof err === "object" ? (err as { code?: unknown }).code : undefined;
|
|
197
|
+
if (code === "ENOENT" || code === "ECONNREFUSED") return false;
|
|
198
|
+
logger.warn("RPC --listen socket probe failed closed", {
|
|
199
|
+
socketPath,
|
|
200
|
+
code: typeof code === "string" ? code : undefined,
|
|
201
|
+
error: err instanceof Error ? err.message : String(err),
|
|
202
|
+
});
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
94
207
|
export function requestRpcEditor(
|
|
95
208
|
pendingRequests: Map<string, PendingExtensionRequest>,
|
|
96
209
|
output: RpcOutput,
|
|
@@ -230,6 +343,7 @@ export async function runRpcMode(
|
|
|
230
343
|
emitFrame: gate => output(gate),
|
|
231
344
|
store: gateStore,
|
|
232
345
|
audit: recordAudit,
|
|
346
|
+
providerSupportsTokenCostMetrics: modelSupportsTokenCostMetrics(session.model),
|
|
233
347
|
getUsageSnapshot: () => {
|
|
234
348
|
const stats = session.getSessionStats();
|
|
235
349
|
return { tokens: stats.tokens.total, costUsd: stats.cost };
|
|
@@ -537,14 +651,13 @@ export async function runRpcMode(
|
|
|
537
651
|
unattendedControlPlane,
|
|
538
652
|
});
|
|
539
653
|
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
let orderedChain: Promise<void> = Promise.resolve();
|
|
654
|
+
// Fast-lane commands (cancellation + safe read/control, see
|
|
655
|
+
// isFastLaneRpcCommand) bypass the ordered serial chain and run immediately;
|
|
656
|
+
// everything else runs through a serial chain so causal order is preserved
|
|
657
|
+
// (e.g. an ordered `set_model` after `bash` still applies after the bash
|
|
658
|
+
// result) while the read loop itself never blocks — that is what lets a
|
|
659
|
+
// fast-lane command reach a long-running `bash`/`compact`/`handoff`/`login`
|
|
660
|
+
// instead of being head-of-line-blocked behind it (issue 13).
|
|
548
661
|
const runCommand = async (command: RpcCommand): Promise<void> => {
|
|
549
662
|
try {
|
|
550
663
|
output(await handleCommand(command));
|
|
@@ -556,14 +669,7 @@ export async function runRpcMode(
|
|
|
556
669
|
inFlightCommands.add(task);
|
|
557
670
|
void task.finally(() => inFlightCommands.delete(task));
|
|
558
671
|
};
|
|
559
|
-
const dispatchCommand = (
|
|
560
|
-
if (CANCELLATION_COMMANDS.has(command.type)) {
|
|
561
|
-
trackCommand(runCommand(command));
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
orderedChain = orderedChain.then(() => runCommand(command));
|
|
565
|
-
trackCommand(orderedChain);
|
|
566
|
-
};
|
|
672
|
+
const { dispatch: dispatchCommand } = createRpcCommandScheduler(runCommand, trackCommand);
|
|
567
673
|
|
|
568
674
|
/**
|
|
569
675
|
* Check if shutdown was requested and perform shutdown if so.
|
|
@@ -620,6 +726,14 @@ export async function runRpcMode(
|
|
|
620
726
|
if (options?.listen) {
|
|
621
727
|
const socketPath = options.listen;
|
|
622
728
|
await fs.mkdir(path.dirname(socketPath), { recursive: true }).catch(() => {});
|
|
729
|
+
// Refuse to clobber a live previous owner: probe the path first and only
|
|
730
|
+
// unlink a stale endpoint. A second `--listen` on the same path must not
|
|
731
|
+
// remove the socket another running server is still serving (#606).
|
|
732
|
+
// Unexpected probe failures are treated as alive, so this also refuses
|
|
733
|
+
// rather than unlinking a socket path we could not safely classify.
|
|
734
|
+
if (await isUnixSocketAlive(socketPath)) {
|
|
735
|
+
throw new RpcListenRefusedError(socketPath);
|
|
736
|
+
}
|
|
623
737
|
await fs.rm(socketPath, { force: true }).catch(() => {});
|
|
624
738
|
await registerRpcSession({
|
|
625
739
|
sessionId: session.sessionId,
|
|
@@ -363,7 +363,10 @@ export async function dispatchRpcCommand(
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
case "get_messages": {
|
|
366
|
-
|
|
366
|
+
// Fast-lane read: snapshot the live array so a concurrent ordered
|
|
367
|
+
// turn/compaction mutating session.messages in place cannot make this
|
|
368
|
+
// response serialize a half-rewritten array (#606, issue 13).
|
|
369
|
+
return rpcSuccess(id, "get_messages", { messages: [...session.messages] });
|
|
367
370
|
}
|
|
368
371
|
|
|
369
372
|
case "get_login_providers": {
|
|
@@ -447,7 +450,7 @@ export async function dispatchRpcCommand(
|
|
|
447
450
|
|
|
448
451
|
default: {
|
|
449
452
|
const unknownCommand = command as { type: string };
|
|
450
|
-
return rpcError(
|
|
453
|
+
return rpcError(id, unknownCommand.type, `Unknown command: ${unknownCommand.type}`);
|
|
451
454
|
}
|
|
452
455
|
}
|
|
453
456
|
} catch (err) {
|
|
@@ -93,6 +93,9 @@ export class RpcHostToolBridge {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
setTools(tools: RpcHostToolDefinition[]): AgentTool[] {
|
|
96
|
+
if (tools.some(tool => tool.name === "ask")) {
|
|
97
|
+
throw new Error('RPC host tool "ask" is reserved and cannot be supplied by the host');
|
|
98
|
+
}
|
|
96
99
|
this.#definitions = new Map(tools.map(tool => [tool.name, tool]));
|
|
97
100
|
return tools.map(tool => new RpcHostToolAdapter(tool, this));
|
|
98
101
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Also implements the dispatch-facing {@link RpcUnattendedControlPlane} so the
|
|
15
15
|
* RPC server can route `negotiate_unattended` + `workflow_gate_response` here.
|
|
16
16
|
*/
|
|
17
|
+
import type { Model } from "@gajae-code/ai";
|
|
17
18
|
import type {
|
|
18
19
|
RpcCommand,
|
|
19
20
|
RpcUnattendedAccepted,
|
|
@@ -38,6 +39,20 @@ import { type GateStore, MemoryGateStore, type OpenGateInput, WorkflowGateBroker
|
|
|
38
39
|
*/
|
|
39
40
|
const CHARGED_COMMAND_TYPES = new Set<RpcCommand["type"]>(["bash", "prompt", "steer", "follow_up", "abort_and_prompt"]);
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Derive an explicit `providerSupportsTokenCostMetrics` capability from the
|
|
44
|
+
* active model so unattended negotiation fails closed when token/cost usage
|
|
45
|
+
* cannot be accounted for (#606). Callers that omit a model — or whose model is
|
|
46
|
+
* configured to suppress streaming usage (`compat.supportsUsageInStreaming:
|
|
47
|
+
* false`) — get `false`, which the controller refuses with
|
|
48
|
+
* `unsupported_budget_metric`.
|
|
49
|
+
*/
|
|
50
|
+
export function modelSupportsTokenCostMetrics(model: Model | undefined): boolean {
|
|
51
|
+
if (!model) return false;
|
|
52
|
+
const compat = model.compat as { supportsUsageInStreaming?: boolean } | undefined;
|
|
53
|
+
return compat?.supportsUsageInStreaming !== false;
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
/** Minimal surface a skill runtime / ask tool uses to emit a gate and await its answer. */
|
|
42
57
|
export interface WorkflowGateEmitter {
|
|
43
58
|
/** True only when unattended mode has been negotiated. */
|
|
@@ -92,7 +107,7 @@ export class UnattendedSessionControlPlane implements RpcUnattendedControlPlane,
|
|
|
92
107
|
this.#rejectAllPending(new Error(`unattended run aborted: ${reason}`));
|
|
93
108
|
},
|
|
94
109
|
},
|
|
95
|
-
providerSupportsTokenCostMetrics: this.opts.providerSupportsTokenCostMetrics
|
|
110
|
+
providerSupportsTokenCostMetrics: this.opts.providerSupportsTokenCostMetrics,
|
|
96
111
|
});
|
|
97
112
|
this.#controller = controller;
|
|
98
113
|
this.#broker = new WorkflowGateBroker(this.opts.runId, this.opts.store ?? new MemoryGateStore(), {
|