@gajae-code/coding-agent 0.5.0 → 0.5.1
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 +19 -0
- package/dist/types/async/job-manager.d.ts +26 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/list-models.d.ts +6 -0
- package/dist/types/commands/gc.d.ts +26 -0
- package/dist/types/config/file-lock-gc.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +7 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
- package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
- package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
- package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
- package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +11 -0
- package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
- package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/owner.d.ts +7 -0
- package/dist/types/harness-control-plane/storage.d.ts +20 -0
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +39 -3
- package/dist/types/skill-state/workflow-hud.d.ts +14 -0
- package/dist/types/tools/ask.d.ts +15 -1
- package/dist/types/tools/subagent.d.ts +6 -0
- package/package.json +7 -7
- package/src/async/job-manager.ts +52 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/list-models.ts +13 -1
- package/src/cli.ts +1 -0
- package/src/commands/gc.ts +22 -0
- package/src/commands/harness.ts +7 -3
- package/src/config/file-lock-gc.ts +181 -0
- package/src/config/file-lock.ts +14 -0
- package/src/config/model-profiles.ts +24 -15
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/server.ts +459 -3
- package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
- package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
- package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
- package/src/defaults/gjc-defaults.ts +7 -0
- package/src/defaults/gjc-grok-cli.ts +22 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
- package/src/gjc-runtime/deep-interview-state.ts +324 -0
- package/src/gjc-runtime/gc-render.ts +70 -0
- package/src/gjc-runtime/gc-runtime.ts +403 -0
- package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
- package/src/gjc-runtime/ralplan-runtime.ts +58 -7
- package/src/gjc-runtime/state-renderer.ts +12 -3
- package/src/gjc-runtime/state-runtime.ts +46 -29
- package/src/gjc-runtime/team-gc.ts +49 -0
- package/src/gjc-runtime/team-runtime.ts +179 -2
- package/src/gjc-runtime/tmux-common.ts +14 -0
- package/src/gjc-runtime/tmux-gc.ts +176 -0
- package/src/gjc-runtime/tmux-sessions.ts +49 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +12 -0
- package/src/harness-control-plane/gc-adapter.ts +184 -0
- package/src/harness-control-plane/owner.ts +11 -0
- package/src/harness-control-plane/storage.ts +70 -0
- package/src/internal-urls/docs-index.generated.ts +14 -8
- package/src/main.ts +7 -2
- package/src/modes/components/hook-selector.ts +19 -0
- package/src/modes/components/model-selector.ts +25 -8
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/controllers/command-controller.ts +25 -6
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/modes/rpc/rpc-mode.ts +151 -33
- package/src/modes/shared/agent-wire/command-dispatch.ts +278 -261
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
- package/src/modes/shared/agent-wire/session-registry.ts +109 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/sdk.ts +17 -3
- package/src/session/agent-session.ts +77 -8
- package/src/session/blob-store.ts +59 -3
- package/src/session/session-manager.ts +4 -4
- package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
- package/src/skill-state/workflow-hud.ts +106 -10
- package/src/slash-commands/builtin-registry.ts +3 -2
- package/src/task/executor.ts +9 -0
- package/src/tools/ask.ts +56 -1
- package/src/tools/job.ts +3 -2
- package/src/tools/monitor.ts +36 -1
- package/src/tools/subagent-render.ts +9 -0
- package/src/tools/subagent.ts +26 -2
package/src/main.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { activateModelProfile } from "./config/model-profile-activation";
|
|
|
30
30
|
import { ModelRegistry, ModelsConfigFile } from "./config/model-registry";
|
|
31
31
|
import { resolveCliModel, resolveModelRoleValue, resolveModelScope, type ScopedModel } from "./config/model-resolver";
|
|
32
32
|
import { getDefault, type SettingPath, Settings, settings } from "./config/settings";
|
|
33
|
+
import { BUNDLED_GROK_BUILD_EXTENSION_ID, getBundledGrokBuildExtensionFactory } from "./defaults/gjc-grok-cli";
|
|
33
34
|
import { initializeWithSettings } from "./discovery";
|
|
34
35
|
import { exportFromFile } from "./export/html";
|
|
35
36
|
import type { ExtensionUIContext } from "./extensibility/extensions/types";
|
|
@@ -742,7 +743,9 @@ export async function runRootCommand(
|
|
|
742
743
|
await runListModelsCommand({
|
|
743
744
|
modelRegistry,
|
|
744
745
|
cwd: getProjectDir(),
|
|
745
|
-
|
|
746
|
+
extensionFactories: [
|
|
747
|
+
{ factory: getBundledGrokBuildExtensionFactory(), name: BUNDLED_GROK_BUILD_EXTENSION_ID },
|
|
748
|
+
],
|
|
746
749
|
settingsExtensions: [],
|
|
747
750
|
disabledExtensionIds: [],
|
|
748
751
|
disableExtensionDiscovery: true,
|
|
@@ -975,7 +978,9 @@ export async function runRootCommand(
|
|
|
975
978
|
|
|
976
979
|
if (mode === "rpc" || mode === "rpc-ui") {
|
|
977
980
|
const { runRpcMode } = await import("./modes/rpc/rpc-mode");
|
|
978
|
-
await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined
|
|
981
|
+
await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, {
|
|
982
|
+
listen: parsedArgs.rpcListen,
|
|
983
|
+
});
|
|
979
984
|
} else if (mode === "bridge") {
|
|
980
985
|
const { runBridgeMode } = await import("./modes/bridge/bridge-mode");
|
|
981
986
|
await runBridgeMode(session, setToolUIContext);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Displays a list of string options with keyboard navigation.
|
|
4
4
|
*/
|
|
5
5
|
import {
|
|
6
|
+
type AutocompleteProvider,
|
|
6
7
|
Container,
|
|
7
8
|
Editor,
|
|
8
9
|
Markdown,
|
|
@@ -73,6 +74,12 @@ export interface HookSelectorOptions {
|
|
|
73
74
|
optionLabel: string;
|
|
74
75
|
onSubmit: (text: string) => void;
|
|
75
76
|
};
|
|
77
|
+
/**
|
|
78
|
+
* Autocomplete provider for the inline custom-input editor. When present,
|
|
79
|
+
* the "Other (type your own)" editor gains the same `@` file-link and `/`
|
|
80
|
+
* completion behavior as the main prompt editor.
|
|
81
|
+
*/
|
|
82
|
+
autocompleteProvider?: AutocompleteProvider;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
class OutlinedList extends Container {
|
|
@@ -320,6 +327,7 @@ export class HookSelectorComponent extends Container {
|
|
|
320
327
|
#helpTextComponent: Text;
|
|
321
328
|
#baseHelpText: string;
|
|
322
329
|
#tui: TUI | undefined;
|
|
330
|
+
#autocompleteProvider: AutocompleteProvider | undefined;
|
|
323
331
|
constructor(
|
|
324
332
|
title: string,
|
|
325
333
|
options: string[],
|
|
@@ -342,6 +350,7 @@ export class HookSelectorComponent extends Container {
|
|
|
342
350
|
this.#outline = opts?.outline === true;
|
|
343
351
|
this.#customInput = opts?.customInput;
|
|
344
352
|
this.#tui = opts?.tui;
|
|
353
|
+
this.#autocompleteProvider = opts?.autocompleteProvider;
|
|
345
354
|
|
|
346
355
|
this.addChild(new DynamicBorder());
|
|
347
356
|
this.addChild(new Spacer(1));
|
|
@@ -491,6 +500,13 @@ export class HookSelectorComponent extends Container {
|
|
|
491
500
|
|
|
492
501
|
/** Keys while the inline custom-input editor is open below the option list. */
|
|
493
502
|
#handleInputModeKey(keyData: string, editor: Editor): void {
|
|
503
|
+
// While the autocomplete dropdown is open, every key belongs to the
|
|
504
|
+
// editor (navigate/apply/cancel the suggestion) instead of submitting
|
|
505
|
+
// or backing out of input mode.
|
|
506
|
+
if (editor.isAutocompleteOpen()) {
|
|
507
|
+
editor.handleInput(keyData);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
494
510
|
// Escape backs out to option selection instead of cancelling the dialog,
|
|
495
511
|
// so a stray Esc never throws away the question context.
|
|
496
512
|
if (matchesKey(keyData, "escape") || matchesAppInterrupt(keyData)) {
|
|
@@ -521,6 +537,9 @@ export class HookSelectorComponent extends Container {
|
|
|
521
537
|
editor.setBorderVisible(false);
|
|
522
538
|
editor.setPromptGutter("> ");
|
|
523
539
|
editor.disableSubmit = true;
|
|
540
|
+
if (this.#autocompleteProvider) {
|
|
541
|
+
editor.setAutocompleteProvider(this.#autocompleteProvider);
|
|
542
|
+
}
|
|
524
543
|
this.#inlineEditor = editor;
|
|
525
544
|
this.#inputArea.addChild(new Spacer(1));
|
|
526
545
|
this.#inputArea.addChild(editor);
|
|
@@ -705,6 +705,15 @@ export class ModelSelectorComponent extends Container {
|
|
|
705
705
|
return this.#getMissingProviders(profileOrProfiles).length === 0;
|
|
706
706
|
}
|
|
707
707
|
|
|
708
|
+
/**
|
|
709
|
+
* A preset group is a list of alternative presets, not an all-or-nothing
|
|
710
|
+
* bundle. Treat the group as usable when at least one member preset has all
|
|
711
|
+
* of its required providers authenticated.
|
|
712
|
+
*/
|
|
713
|
+
#isPresetGroupUsable(profiles: ModelProfileDefinition[]): boolean {
|
|
714
|
+
return profiles.some(profile => this.#isPresetAuthenticated(profile));
|
|
715
|
+
}
|
|
716
|
+
|
|
708
717
|
async #refreshProviderAuth(): Promise<void> {
|
|
709
718
|
const providers = new Set<string>();
|
|
710
719
|
for (const profiles of this.#getPresetGroups().values()) {
|
|
@@ -763,7 +772,7 @@ export class ModelSelectorComponent extends Container {
|
|
|
763
772
|
continue;
|
|
764
773
|
}
|
|
765
774
|
if (row.kind === "group") {
|
|
766
|
-
const authenticated = this.#
|
|
775
|
+
const authenticated = this.#isPresetGroupUsable(row.profiles);
|
|
767
776
|
const mark = this.#providerAuthPending ? "…" : authenticated ? "✓" : "✗";
|
|
768
777
|
const label = `${mark} ${row.groupId}`;
|
|
769
778
|
const renderedLabel = selected ? theme.fg("accent", label) : authenticated ? label : theme.fg("dim", label);
|
|
@@ -1158,18 +1167,26 @@ export class ModelSelectorComponent extends Container {
|
|
|
1158
1167
|
this.#switchToModelMode();
|
|
1159
1168
|
return;
|
|
1160
1169
|
}
|
|
1161
|
-
|
|
1162
|
-
|
|
1170
|
+
if (row.kind === "group") {
|
|
1171
|
+
// A group is a list of alternative presets; only surface a login hint
|
|
1172
|
+
// when none of its members are usable. A partially-usable group stays
|
|
1173
|
+
// navigable so the user can drill in and pick a usable member.
|
|
1174
|
+
if (!this.#isPresetGroupUsable(row.profiles)) {
|
|
1175
|
+
const missing = this.#getMissingProviders(row.profiles);
|
|
1176
|
+
this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
|
|
1177
|
+
this.#renderPresetLanding();
|
|
1178
|
+
}
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const missing = this.#getMissingProviders(row.profile);
|
|
1163
1182
|
if (missing.length > 0) {
|
|
1164
1183
|
this.#presetLoginHint = `Run ${missing.map(provider => `/login ${provider}`).join(", ")}`;
|
|
1165
1184
|
this.#renderPresetLanding();
|
|
1166
1185
|
return;
|
|
1167
1186
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
this.#renderPresetLanding();
|
|
1172
|
-
}
|
|
1187
|
+
this.#previewProfileName = row.profile.name;
|
|
1188
|
+
this.#presetLoginHint = undefined;
|
|
1189
|
+
this.#renderPresetLanding();
|
|
1173
1190
|
}
|
|
1174
1191
|
|
|
1175
1192
|
#beginActionMenuOrSelect(item: ModelItem | CanonicalModelItem): void {
|
|
@@ -66,7 +66,7 @@ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string |
|
|
|
66
66
|
const gajaeSegment: StatusLineSegment = {
|
|
67
67
|
id: "gajae",
|
|
68
68
|
render(_ctx) {
|
|
69
|
-
return { content: theme.fg("accent", "
|
|
69
|
+
return { content: theme.fg("accent", "🦞"), visible: true };
|
|
70
70
|
},
|
|
71
71
|
};
|
|
72
72
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@gajae-code/tui";
|
|
14
14
|
import { formatDuration, Snowflake, setProjectDir } from "@gajae-code/utils";
|
|
15
15
|
import { $ } from "bun";
|
|
16
|
+
import { jobElapsedMs } from "../../async";
|
|
16
17
|
import { reset as resetCapabilities } from "../../capability";
|
|
17
18
|
import { clearClaudePluginRootsCache } from "../../discovery/helpers";
|
|
18
19
|
import { loadCustomShare } from "../../export/custom-share";
|
|
@@ -1279,7 +1280,7 @@ const BAR_WIDTH_MAX = 24;
|
|
|
1279
1280
|
const BAR_WIDTH_MIN = 4;
|
|
1280
1281
|
|
|
1281
1282
|
function renderJobLine(job: AsyncJobSnapshotItem, now: number): string {
|
|
1282
|
-
const duration = formatDuration(
|
|
1283
|
+
const duration = formatDuration(jobElapsedMs(job, now));
|
|
1283
1284
|
const status = formatJobStatus(job.status);
|
|
1284
1285
|
return `${theme.fg("dim", job.id)} ${theme.fg("dim", `[${job.type}]`)} ${status} ${theme.fg("dim", `(${duration})`)}`;
|
|
1285
1286
|
}
|
|
@@ -1538,7 +1539,7 @@ function resolveColumnWidth(count: number, available: number, trailing: number):
|
|
|
1538
1539
|
return ideal;
|
|
1539
1540
|
}
|
|
1540
1541
|
|
|
1541
|
-
function renderUsageReports(
|
|
1542
|
+
export function renderUsageReports(
|
|
1542
1543
|
reports: UsageReport[],
|
|
1543
1544
|
uiTheme: typeof theme,
|
|
1544
1545
|
nowMs: number,
|
|
@@ -1592,17 +1593,35 @@ function renderUsageReports(
|
|
|
1592
1593
|
|
|
1593
1594
|
lines.push(uiTheme.bold(uiTheme.fg("accent", providerName)));
|
|
1594
1595
|
|
|
1596
|
+
// Stable account column order shared across every window group, so each
|
|
1597
|
+
// account keeps the same column in the 5h / 7d / ... rows. Rank accounts
|
|
1598
|
+
// by total usage across their windows (a per-account value identical for
|
|
1599
|
+
// every group), tie-broken by label for determinism.
|
|
1600
|
+
const rankedAccounts = providerReports
|
|
1601
|
+
.map(report => {
|
|
1602
|
+
const [firstLimit] = report.limits;
|
|
1603
|
+
return {
|
|
1604
|
+
report,
|
|
1605
|
+
total: report.limits.reduce((sum, limit) => sum + (resolveFraction(limit) ?? 0), 0),
|
|
1606
|
+
label: firstLimit ? formatAccountLabel(firstLimit, report, 0) : formatUnlimitedReportLabel(report, 0),
|
|
1607
|
+
};
|
|
1608
|
+
})
|
|
1609
|
+
.sort((a, b) => (a.total !== b.total ? b.total - a.total : a.label.localeCompare(b.label)));
|
|
1610
|
+
const accountRank = new Map<UsageReport, number>();
|
|
1611
|
+
rankedAccounts.forEach((entry, rank) => {
|
|
1612
|
+
accountRank.set(entry.report, rank);
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1595
1615
|
const renderableGroups = Array.from(limitGroups.values()).map(group => {
|
|
1596
1616
|
const entries = group.limits.map((limit, index) => ({
|
|
1597
1617
|
limit,
|
|
1598
1618
|
report: group.reports[index],
|
|
1599
|
-
fraction: resolveFraction(limit),
|
|
1600
1619
|
index,
|
|
1601
1620
|
}));
|
|
1602
1621
|
entries.sort((a, b) => {
|
|
1603
|
-
const
|
|
1604
|
-
const
|
|
1605
|
-
if (
|
|
1622
|
+
const aRank = accountRank.get(a.report) ?? Number.MAX_SAFE_INTEGER;
|
|
1623
|
+
const bRank = accountRank.get(b.report) ?? Number.MAX_SAFE_INTEGER;
|
|
1624
|
+
if (aRank !== bRank) return aRank - bRank;
|
|
1606
1625
|
return a.index - b.index;
|
|
1607
1626
|
});
|
|
1608
1627
|
const sortedLimits = entries.map(entry => entry.limit);
|
|
@@ -645,6 +645,9 @@ export class ExtensionUiController {
|
|
|
645
645
|
timeout: dialogOptions?.timeout,
|
|
646
646
|
onTimeout: dialogOptions?.onTimeout,
|
|
647
647
|
tui: this.ctx.ui,
|
|
648
|
+
// Share the main prompt editor's autocomplete provider so the
|
|
649
|
+
// inline "Other (type your own)" editor supports `@` file links.
|
|
650
|
+
autocompleteProvider: dialogOptions?.customInput ? this.ctx.editor.getAutocompleteProvider() : undefined,
|
|
648
651
|
outline: dialogOptions?.outline,
|
|
649
652
|
wrapFocused: dialogOptions?.wrapFocused,
|
|
650
653
|
scrollTitleRows,
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* - Events: AgentSessionEvent objects streamed as they occur
|
|
11
11
|
* - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
|
|
12
12
|
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs/promises";
|
|
13
15
|
import * as path from "node:path";
|
|
14
|
-
import { $
|
|
16
|
+
import { $pickenv, readLines, Snowflake } from "@gajae-code/utils";
|
|
15
17
|
import type {
|
|
16
18
|
ExtensionUIContext,
|
|
17
19
|
ExtensionUIDialogOptions,
|
|
@@ -23,6 +25,7 @@ import { initializeExtensions } from "../runtime-init";
|
|
|
23
25
|
import { dispatchRpcCommand } from "../shared/agent-wire/command-dispatch";
|
|
24
26
|
import { AgentWireFrameSequencer, toAgentWireEventFrame } from "../shared/agent-wire/event-envelope";
|
|
25
27
|
import { rpcError as error } from "../shared/agent-wire/responses";
|
|
28
|
+
import { registerRpcSession, unregisterRpcSession } from "../shared/agent-wire/session-registry";
|
|
26
29
|
import { defaultAuditPath, UnattendedAuditLog } from "../shared/agent-wire/unattended-audit";
|
|
27
30
|
import { UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
|
|
28
31
|
import { FileGateStore } from "../shared/agent-wire/workflow-gate-broker";
|
|
@@ -70,13 +73,15 @@ function parseValueDialogResponse(
|
|
|
70
73
|
return undefined;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
function
|
|
74
|
-
const raw = $
|
|
76
|
+
export function shouldEmitRpcTitlesForTest(): boolean {
|
|
77
|
+
const raw = $pickenv("GJC_RPC_EMIT_TITLE", "PI_RPC_EMIT_TITLE");
|
|
75
78
|
if (!raw) return false;
|
|
76
79
|
const normalized = raw.trim().toLowerCase();
|
|
77
80
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
const shouldEmitRpcTitles = shouldEmitRpcTitlesForTest;
|
|
84
|
+
|
|
80
85
|
function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "exceeded" | "aborted" | "info" {
|
|
81
86
|
if (event.includes("denied")) return "denied";
|
|
82
87
|
if (event.includes("exceeded")) return "exceeded";
|
|
@@ -156,6 +161,7 @@ export function requestRpcEditor(
|
|
|
156
161
|
export async function runRpcMode(
|
|
157
162
|
session: AgentSession,
|
|
158
163
|
setToolUIContext?: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
|
|
164
|
+
options?: { listen?: string },
|
|
159
165
|
): Promise<never> {
|
|
160
166
|
// Signal to RPC clients that the server is ready to accept commands
|
|
161
167
|
// Suppress terminal notifications: they write \x07 (BEL) or OSC sequences directly to
|
|
@@ -164,10 +170,18 @@ export async function runRpcMode(
|
|
|
164
170
|
// may write there.
|
|
165
171
|
process.env.PI_NOTIFICATIONS = "off";
|
|
166
172
|
|
|
167
|
-
|
|
173
|
+
// Frames go to a swappable sink: stdout for stdio, the active client socket for a
|
|
174
|
+
// persistent --listen (UDS) server. Defaults to stdout, so the stdio path is unchanged.
|
|
175
|
+
let frameSink = (line: string): void => {
|
|
176
|
+
process.stdout.write(line);
|
|
177
|
+
};
|
|
168
178
|
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
|
|
169
|
-
|
|
179
|
+
frameSink(`${JSON.stringify(obj)}\n`);
|
|
170
180
|
};
|
|
181
|
+
// stdio announces readiness immediately; the UDS server announces it per client connection.
|
|
182
|
+
if (!options?.listen) {
|
|
183
|
+
output({ type: "ready" });
|
|
184
|
+
}
|
|
171
185
|
const emitRpcTitles = shouldEmitRpcTitles();
|
|
172
186
|
const decodeError = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
173
187
|
|
|
@@ -231,11 +245,20 @@ export async function runRpcMode(
|
|
|
231
245
|
// Shutdown request flag (wrapped in object to allow mutation with const)
|
|
232
246
|
const shutdownState = { requested: false };
|
|
233
247
|
let shutdownStarted = false;
|
|
248
|
+
// Tracks in-flight non-blocking command handlers so shutdown can drain them.
|
|
249
|
+
const inFlightCommands = new Set<Promise<void>>();
|
|
234
250
|
async function shutdown(exitCode: number, reason: string): Promise<never> {
|
|
235
251
|
if (shutdownStarted) {
|
|
236
252
|
process.exit(exitCode);
|
|
237
253
|
}
|
|
238
254
|
shutdownStarted = true;
|
|
255
|
+
// Let in-flight non-blocking commands (bash/compact/handoff) finish and emit
|
|
256
|
+
// their responses before teardown, bounded so a never-resolving login cannot
|
|
257
|
+
// wedge shutdown (issue 13).
|
|
258
|
+
if (inFlightCommands.size > 0) {
|
|
259
|
+
await Promise.race([Promise.allSettled([...inFlightCommands]), Bun.sleep(5000)]);
|
|
260
|
+
}
|
|
261
|
+
await unregisterRpcSession(session.sessionId).catch(() => {});
|
|
239
262
|
hostToolBridge.rejectAllPending(`${reason} before host tool execution completed`);
|
|
240
263
|
hostUriBridge.clear(`${reason} before host URI request completed`);
|
|
241
264
|
try {
|
|
@@ -399,7 +422,7 @@ export async function runRpcMode(
|
|
|
399
422
|
}
|
|
400
423
|
|
|
401
424
|
setTitle(title: string): void {
|
|
402
|
-
// Title updates are low-value noise for most RPC hosts; opt in via
|
|
425
|
+
// Title updates are low-value noise for most RPC hosts; opt in via GJC_RPC_EMIT_TITLE=1.
|
|
403
426
|
if (!emitRpcTitles) return;
|
|
404
427
|
this.output({
|
|
405
428
|
type: "extension_ui_request",
|
|
@@ -514,6 +537,34 @@ export async function runRpcMode(
|
|
|
514
537
|
unattendedControlPlane,
|
|
515
538
|
});
|
|
516
539
|
|
|
540
|
+
// Cancellation commands must interrupt in-flight work, so they bypass the ordered
|
|
541
|
+
// queue and run immediately. Everything else runs through a serial chain so causal
|
|
542
|
+
// order is preserved (e.g. `get_state` after `bash` still observes the bash result)
|
|
543
|
+
// while the read loop itself never blocks — that is what lets a cancellation command
|
|
544
|
+
// reach a long-running `bash`/`compact`/`handoff`/`login` instead of being
|
|
545
|
+
// head-of-line-blocked behind it (issue 13).
|
|
546
|
+
const CANCELLATION_COMMANDS = new Set<RpcCommand["type"]>(["abort", "abort_bash", "abort_retry"]);
|
|
547
|
+
let orderedChain: Promise<void> = Promise.resolve();
|
|
548
|
+
const runCommand = async (command: RpcCommand): Promise<void> => {
|
|
549
|
+
try {
|
|
550
|
+
output(await handleCommand(command));
|
|
551
|
+
} catch (err) {
|
|
552
|
+
output(error(command.id, command.type, decodeError(err)));
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
const trackCommand = (task: Promise<void>): void => {
|
|
556
|
+
inFlightCommands.add(task);
|
|
557
|
+
void task.finally(() => inFlightCommands.delete(task));
|
|
558
|
+
};
|
|
559
|
+
const dispatchCommand = (command: RpcCommand): void => {
|
|
560
|
+
if (CANCELLATION_COMMANDS.has(command.type)) {
|
|
561
|
+
trackCommand(runCommand(command));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
orderedChain = orderedChain.then(() => runCommand(command));
|
|
565
|
+
trackCommand(orderedChain);
|
|
566
|
+
};
|
|
567
|
+
|
|
517
568
|
/**
|
|
518
569
|
* Check if shutdown was requested and perform shutdown if so.
|
|
519
570
|
* Called after handling each command when waiting for the next command.
|
|
@@ -523,59 +574,126 @@ export async function runRpcMode(
|
|
|
523
574
|
await shutdown(0, "RPC shutdown requested");
|
|
524
575
|
}
|
|
525
576
|
|
|
526
|
-
//
|
|
527
|
-
//
|
|
577
|
+
// Parse + route a single inbound JSONL frame. Shared by the stdio reader and the
|
|
578
|
+
// persistent UDS server so both transports use the same command surface.
|
|
528
579
|
const inputDecoder = new TextDecoder("utf-8", { fatal: false });
|
|
529
|
-
|
|
530
|
-
const text = inputDecoder.decode(line).trim();
|
|
531
|
-
if (!text) continue;
|
|
532
|
-
|
|
580
|
+
async function handleInboundLine(text: string): Promise<void> {
|
|
533
581
|
let parsed: unknown;
|
|
534
582
|
try {
|
|
535
583
|
parsed = JSON.parse(text);
|
|
536
584
|
} catch (err) {
|
|
537
585
|
output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
|
|
538
|
-
|
|
586
|
+
return;
|
|
539
587
|
}
|
|
540
|
-
|
|
541
588
|
try {
|
|
542
|
-
// Handle extension UI responses
|
|
543
589
|
if ((parsed as RpcExtensionUIResponse).type === "extension_ui_response") {
|
|
544
590
|
const response = parsed as RpcExtensionUIResponse;
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
pending.resolve(response);
|
|
548
|
-
}
|
|
549
|
-
continue;
|
|
591
|
+
pendingExtensionRequests.get(response.id)?.resolve(response);
|
|
592
|
+
return;
|
|
550
593
|
}
|
|
551
|
-
|
|
552
594
|
if (isRpcHostToolResult(parsed)) {
|
|
553
595
|
hostToolBridge.handleResult(parsed);
|
|
554
|
-
|
|
596
|
+
return;
|
|
555
597
|
}
|
|
556
|
-
|
|
557
598
|
if (isRpcHostToolUpdate(parsed)) {
|
|
558
599
|
hostToolBridge.handleUpdate(parsed);
|
|
559
|
-
|
|
600
|
+
return;
|
|
560
601
|
}
|
|
561
|
-
|
|
562
602
|
if (isRpcHostUriResult(parsed)) {
|
|
563
603
|
hostUriBridge.handleResult(parsed);
|
|
564
|
-
|
|
604
|
+
return;
|
|
565
605
|
}
|
|
566
|
-
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
output(response);
|
|
571
|
-
|
|
572
|
-
// Check for deferred shutdown request (idle between commands)
|
|
606
|
+
// Ordered commands run through a serial chain to preserve causal order; the
|
|
607
|
+
// reader never blocks, so cancellation commands stay responsive even while a
|
|
608
|
+
// long command is in flight (issue 13).
|
|
609
|
+
dispatchCommand(parsed as RpcCommand);
|
|
573
610
|
await checkShutdownRequested();
|
|
574
611
|
} catch (err) {
|
|
575
612
|
output(error(undefined, "parse", `Failed to parse command: ${decodeError(err)}`));
|
|
576
613
|
}
|
|
577
614
|
}
|
|
578
615
|
|
|
616
|
+
// Persistent UDS server (issue 09): keep the AgentSession alive across client
|
|
617
|
+
// reconnects instead of exiting on stdin EOF. Frames route to the active client
|
|
618
|
+
// socket; while no client is connected they are dropped (clients resync via
|
|
619
|
+
// get_state/get_messages on reconnect).
|
|
620
|
+
if (options?.listen) {
|
|
621
|
+
const socketPath = options.listen;
|
|
622
|
+
await fs.mkdir(path.dirname(socketPath), { recursive: true }).catch(() => {});
|
|
623
|
+
await fs.rm(socketPath, { force: true }).catch(() => {});
|
|
624
|
+
await registerRpcSession({
|
|
625
|
+
sessionId: session.sessionId,
|
|
626
|
+
pid: process.pid,
|
|
627
|
+
transport: "socket",
|
|
628
|
+
cwd: session.sessionManager.getCwd(),
|
|
629
|
+
model: session.model?.id,
|
|
630
|
+
startedAt: new Date().toISOString(),
|
|
631
|
+
endpoint: socketPath,
|
|
632
|
+
}).catch(() => {});
|
|
633
|
+
|
|
634
|
+
const noopSink = (_line: string): void => {};
|
|
635
|
+
let currentSocket: object | undefined;
|
|
636
|
+
let buf = "";
|
|
637
|
+
Bun.listen({
|
|
638
|
+
unix: socketPath,
|
|
639
|
+
socket: {
|
|
640
|
+
open(socket) {
|
|
641
|
+
currentSocket = socket;
|
|
642
|
+
buf = "";
|
|
643
|
+
frameSink = (line: string) => {
|
|
644
|
+
socket.write(line);
|
|
645
|
+
};
|
|
646
|
+
output({ type: "ready" });
|
|
647
|
+
},
|
|
648
|
+
data(socket, data) {
|
|
649
|
+
if (socket !== currentSocket) return;
|
|
650
|
+
buf += inputDecoder.decode(data);
|
|
651
|
+
while (true) {
|
|
652
|
+
const nl = buf.indexOf("\n");
|
|
653
|
+
if (nl < 0) break;
|
|
654
|
+
const text = buf.slice(0, nl).trim();
|
|
655
|
+
buf = buf.slice(nl + 1);
|
|
656
|
+
if (text) void handleInboundLine(text);
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
close(socket) {
|
|
660
|
+
if (socket === currentSocket) {
|
|
661
|
+
currentSocket = undefined;
|
|
662
|
+
frameSink = noopSink;
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
error() {},
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const onSignal = (): void => {
|
|
670
|
+
void shutdown(0, "RPC socket server signal");
|
|
671
|
+
};
|
|
672
|
+
process.on("SIGINT", onSignal);
|
|
673
|
+
process.on("SIGTERM", onSignal);
|
|
674
|
+
// Block until an explicit shutdown (signal/extension) calls process.exit.
|
|
675
|
+
await new Promise<never>(() => {});
|
|
676
|
+
throw new Error("RPC socket server returned unexpectedly");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Register this stdio RPC session so other processes can discover it (issue 10).
|
|
680
|
+
await registerRpcSession({
|
|
681
|
+
sessionId: session.sessionId,
|
|
682
|
+
pid: process.pid,
|
|
683
|
+
transport: "stdio",
|
|
684
|
+
cwd: session.sessionManager.getCwd(),
|
|
685
|
+
model: session.model?.id,
|
|
686
|
+
startedAt: new Date().toISOString(),
|
|
687
|
+
}).catch(() => {});
|
|
688
|
+
|
|
689
|
+
// Listen for JSONL input using Bun's stdin. Parse frame-by-frame so a malformed
|
|
690
|
+
// command reports a parse error without poisoning the whole long-lived RPC session.
|
|
691
|
+
for await (const line of readLines(Bun.stdin.stream())) {
|
|
692
|
+
const text = inputDecoder.decode(line).trim();
|
|
693
|
+
if (!text) continue;
|
|
694
|
+
await handleInboundLine(text);
|
|
695
|
+
}
|
|
696
|
+
|
|
579
697
|
// stdin closed — RPC client is gone, flush durable state and exit cleanly
|
|
580
698
|
await shutdown(0, "RPC client disconnected");
|
|
581
699
|
throw new Error("RPC shutdown returned unexpectedly");
|