@gajae-code/coding-agent 0.7.7 → 0.7.9
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 +38 -1
- package/README.md +14 -0
- package/dist/types/cli/local-provider-smoke.d.ts +59 -0
- package/dist/types/cli/notify-cli.d.ts +1 -1
- package/dist/types/cli/update-cli.d.ts +24 -1
- package/dist/types/commands/local-provider.d.ts +34 -0
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/model-profile-activation.d.ts +19 -2
- package/dist/types/config/model-profiles.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +9 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +45 -0
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/coordinator-mcp/server.d.ts +8 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +14 -5
- package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +3 -1
- package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
- package/dist/types/modes/components/hook-input.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +11 -0
- package/dist/types/modes/components/settings-selector.d.ts +4 -1
- package/dist/types/modes/components/status-line/types.d.ts +5 -7
- package/dist/types/modes/components/status-line.d.ts +7 -3
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +21 -1
- package/dist/types/modes/tmux-scroll.d.ts +18 -0
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
- package/dist/types/notifications/daemon-paths.d.ts +9 -0
- package/dist/types/notifications/telegram-daemon-cli.d.ts +21 -4
- package/dist/types/notifications/telegram-daemon.d.ts +1 -10
- package/dist/types/tools/ask.d.ts +24 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
- package/package.json +7 -7
- package/src/cli/local-provider-smoke.ts +632 -0
- package/src/cli/notify-cli.ts +5 -3
- package/src/cli/update-cli.ts +155 -13
- package/src/cli.ts +19 -0
- package/src/commands/launch.ts +46 -1
- package/src/commands/local-provider.ts +62 -0
- package/src/commands/notify.ts +1 -1
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profile-activation.ts +92 -11
- package/src/config/model-profiles.ts +15 -2
- package/src/config/model-registry.ts +136 -6
- package/src/config/models-config-schema.ts +9 -0
- package/src/config/settings-schema.ts +48 -0
- package/src/config/settings.ts +11 -0
- package/src/coordinator-mcp/server.ts +280 -36
- package/src/deep-interview/render-middleware.ts +1 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -6
- package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
- package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
- package/src/gjc-runtime/launch-tmux.ts +321 -38
- package/src/gjc-runtime/launch-worktree.ts +16 -2
- package/src/gjc-runtime/team-runtime.ts +87 -7
- package/src/gjc-runtime/tmux-common.ts +16 -0
- package/src/gjc-runtime/tmux-sessions.ts +13 -9
- package/src/gjc-runtime/ultragoal-guard.ts +11 -5
- package/src/internal-urls/docs-index.generated.ts +10 -9
- package/src/modes/components/agent-dashboard.ts +2 -1
- package/src/modes/components/custom-model-preset-wizard.ts +31 -192
- package/src/modes/components/hook-editor.ts +7 -4
- package/src/modes/components/hook-input.ts +4 -0
- package/src/modes/components/model-selector.ts +260 -12
- package/src/modes/components/session-observer-overlay.ts +3 -3
- package/src/modes/components/settings-selector.ts +506 -5
- package/src/modes/components/status-line/segments.ts +16 -19
- package/src/modes/components/status-line/types.ts +6 -2
- package/src/modes/components/status-line.ts +70 -35
- package/src/modes/controllers/event-controller.ts +98 -4
- package/src/modes/controllers/extension-ui-controller.ts +5 -0
- package/src/modes/controllers/input-controller.ts +15 -0
- package/src/modes/controllers/selector-controller.ts +142 -11
- package/src/modes/interactive-mode.ts +7 -2
- package/src/modes/prompt-action-autocomplete.ts +8 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +11 -4
- package/src/modes/tmux-scroll.ts +92 -0
- package/src/modes/types.ts +6 -1
- package/src/modes/utils/terminal-bell.ts +48 -0
- package/src/notifications/daemon-paths.ts +22 -0
- package/src/notifications/telegram-daemon-cli.ts +125 -13
- package/src/notifications/telegram-daemon.ts +6 -21
- package/src/prompts/system/system-prompt.md +5 -0
- package/src/sdk.ts +10 -1
- package/src/session/agent-session.ts +84 -6
- package/src/slash-commands/builtin-registry.ts +106 -13
- package/src/tools/ask.ts +11 -1
- package/src/tools/eval.ts +2 -2
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/ultragoal-ask-guard.ts +39 -5
|
@@ -29,6 +29,7 @@ interface PromptActionAutocompleteOptions {
|
|
|
29
29
|
copyCurrentLine: () => void;
|
|
30
30
|
copyPrompt: () => void;
|
|
31
31
|
pasteImage: () => void;
|
|
32
|
+
scrollTmuxToPreviousUserInput: () => void;
|
|
32
33
|
undo: (prefix: string) => void;
|
|
33
34
|
moveCursorToMessageEnd: () => void;
|
|
34
35
|
moveCursorToMessageStart: () => void;
|
|
@@ -378,6 +379,13 @@ export function createPromptActionAutocompleteProvider(
|
|
|
378
379
|
keywords: ["paste", "image", "clipboard", "screenshot", "attach", "vision"],
|
|
379
380
|
execute: options.pasteImage,
|
|
380
381
|
},
|
|
382
|
+
{
|
|
383
|
+
id: "tmux-previous-user-input",
|
|
384
|
+
label: "Scroll to previous user input",
|
|
385
|
+
description: "tmux copy-mode",
|
|
386
|
+
keywords: ["scroll", "tmux", "previous", "user", "input", "prompt", "history"],
|
|
387
|
+
execute: options.scrollTmuxToPreviousUserInput,
|
|
388
|
+
},
|
|
381
389
|
{
|
|
382
390
|
id: "undo",
|
|
383
391
|
label: "Undo",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* the interactive select/editor UI) when an unattended controller + gate broker
|
|
12
12
|
* are attached is wired with the transport in #321 and exercised by #323.
|
|
13
13
|
*/
|
|
14
|
-
import type { RpcJsonSchema } from "../../rpc/rpc-types";
|
|
14
|
+
import type { RpcJsonSchema, RpcWorkflowGateKind, RpcWorkflowStage } from "../../rpc/rpc-types";
|
|
15
15
|
import type { OpenGateInput } from "./workflow-gate-broker";
|
|
16
16
|
|
|
17
17
|
/** "Other (type your own)" sentinel, mirroring the interactive ask tool. */
|
|
@@ -26,6 +26,11 @@ export interface AskGateDeepInterviewState {
|
|
|
26
26
|
ambiguity: number;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export interface AskGateWorkflowGateMeta {
|
|
30
|
+
stage: RpcWorkflowStage;
|
|
31
|
+
kind: RpcWorkflowGateKind;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
export interface AskGateQuestion {
|
|
30
35
|
id: string;
|
|
31
36
|
question: string;
|
|
@@ -37,6 +42,8 @@ export interface AskGateQuestion {
|
|
|
37
42
|
* `stage_state`; when absent, the question text is regex-parsed as a fallback.
|
|
38
43
|
*/
|
|
39
44
|
deepInterview?: AskGateDeepInterviewState;
|
|
45
|
+
/** Override the emitted workflow gate address for non-deep-interview ask prompts. */
|
|
46
|
+
workflowGate?: AskGateWorkflowGateMeta;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export interface AskGateResult {
|
|
@@ -157,13 +164,13 @@ function structuredDeepInterviewState(meta: AskGateDeepInterviewState): Record<s
|
|
|
157
164
|
return state;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
/** Build the `workflow_gate` open-input for one
|
|
167
|
+
/** Build the `workflow_gate` open-input for one ask question. */
|
|
161
168
|
export function questionToGate(question: AskGateQuestion): OpenGateInput {
|
|
162
169
|
const labels = question.options.map(o => o.label);
|
|
163
170
|
const schema = questionAnswerSchema(question, labels);
|
|
164
171
|
return {
|
|
165
|
-
stage: "deep-interview",
|
|
166
|
-
kind: "question",
|
|
172
|
+
stage: question.workflowGate?.stage ?? "deep-interview",
|
|
173
|
+
kind: question.workflowGate?.kind ?? "question",
|
|
167
174
|
schema,
|
|
168
175
|
options: question.options.map((o, i) => ({
|
|
169
176
|
value: o.label,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { resolveGjcTmuxCommand } from "../gjc-runtime/tmux-common";
|
|
2
|
+
|
|
3
|
+
// tmux copy-mode pads lines to the pane width, so allow trailing spaces before
|
|
4
|
+
// the line end while keeping the match scoped to the standalone `user` label.
|
|
5
|
+
export const TMUX_PREVIOUS_USER_INPUT_SEARCH_PATTERN = "^ *user *$";
|
|
6
|
+
|
|
7
|
+
type TmuxScrollFailureReason =
|
|
8
|
+
| "not_inside_tmux"
|
|
9
|
+
| "tmux_command_failed"
|
|
10
|
+
| "copy_mode_failed"
|
|
11
|
+
| "history_bottom_failed"
|
|
12
|
+
| "search_failed";
|
|
13
|
+
|
|
14
|
+
export type TmuxScrollToPreviousUserInputResult =
|
|
15
|
+
| { ok: true }
|
|
16
|
+
| { ok: false; reason: TmuxScrollFailureReason; error?: string };
|
|
17
|
+
|
|
18
|
+
interface TmuxSpawnResult {
|
|
19
|
+
exitCode: number | null;
|
|
20
|
+
stderr?: { toString(): string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type TmuxSpawnSync = (command: string, args: string[], env: NodeJS.ProcessEnv) => TmuxSpawnResult;
|
|
24
|
+
|
|
25
|
+
function defaultSpawnSync(command: string, args: string[], env: NodeJS.ProcessEnv): TmuxSpawnResult {
|
|
26
|
+
return Bun.spawnSync([command, ...args], {
|
|
27
|
+
env,
|
|
28
|
+
stdout: "ignore",
|
|
29
|
+
stderr: "pipe",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getErrorText(result: TmuxSpawnResult): string | undefined {
|
|
34
|
+
const text = result.stderr?.toString().trim();
|
|
35
|
+
return text ? text : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function runTmuxStep(
|
|
39
|
+
command: string,
|
|
40
|
+
args: string[],
|
|
41
|
+
env: NodeJS.ProcessEnv,
|
|
42
|
+
spawnSync: TmuxSpawnSync,
|
|
43
|
+
reason: TmuxScrollFailureReason,
|
|
44
|
+
): TmuxScrollToPreviousUserInputResult {
|
|
45
|
+
try {
|
|
46
|
+
const result = spawnSync(command, args, env);
|
|
47
|
+
if (result.exitCode === 0) return { ok: true };
|
|
48
|
+
return { ok: false, reason, error: getErrorText(result) };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return { ok: false, reason, error: error instanceof Error ? error.message : String(error) };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function scrollTmuxToPreviousUserInput(
|
|
55
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
56
|
+
spawnSync: TmuxSpawnSync = defaultSpawnSync,
|
|
57
|
+
): TmuxScrollToPreviousUserInputResult {
|
|
58
|
+
if (!env.TMUX) return { ok: false, reason: "not_inside_tmux" };
|
|
59
|
+
|
|
60
|
+
let tmuxCommand: string;
|
|
61
|
+
try {
|
|
62
|
+
tmuxCommand = resolveGjcTmuxCommand(env);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
reason: "tmux_command_failed",
|
|
67
|
+
error: error instanceof Error ? error.message : String(error),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const targetPane = env.TMUX_PANE?.trim();
|
|
72
|
+
const targetArgs = targetPane ? ["-t", targetPane] : [];
|
|
73
|
+
const copyModeResult = runTmuxStep(tmuxCommand, ["copy-mode", ...targetArgs], env, spawnSync, "copy_mode_failed");
|
|
74
|
+
if (!copyModeResult.ok) return copyModeResult;
|
|
75
|
+
|
|
76
|
+
const historyBottomResult = runTmuxStep(
|
|
77
|
+
tmuxCommand,
|
|
78
|
+
["send-keys", ...targetArgs, "-X", "history-bottom"],
|
|
79
|
+
env,
|
|
80
|
+
spawnSync,
|
|
81
|
+
"history_bottom_failed",
|
|
82
|
+
);
|
|
83
|
+
if (!historyBottomResult.ok) return historyBottomResult;
|
|
84
|
+
|
|
85
|
+
return runTmuxStep(
|
|
86
|
+
tmuxCommand,
|
|
87
|
+
["send-keys", ...targetArgs, "-X", "search-backward", TMUX_PREVIOUS_USER_INPUT_SEARCH_PATTERN],
|
|
88
|
+
env,
|
|
89
|
+
spawnSync,
|
|
90
|
+
"search_failed",
|
|
91
|
+
);
|
|
92
|
+
}
|
package/src/modes/types.ts
CHANGED
|
@@ -290,7 +290,12 @@ export interface InteractiveModeContext {
|
|
|
290
290
|
dialogOptions?: ExtensionUIDialogOptions,
|
|
291
291
|
): Promise<string | undefined>;
|
|
292
292
|
hideHookSelector(): void;
|
|
293
|
-
showHookInput(
|
|
293
|
+
showHookInput(
|
|
294
|
+
title: string,
|
|
295
|
+
placeholder?: string,
|
|
296
|
+
dialogOptions?: ExtensionUIDialogOptions,
|
|
297
|
+
inputOptions?: { readonly initialValue?: string },
|
|
298
|
+
): Promise<string | undefined>;
|
|
294
299
|
hideHookInput(): void;
|
|
295
300
|
showHookEditor(
|
|
296
301
|
title: string,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { settings } from "../../config/settings";
|
|
2
|
+
|
|
3
|
+
export type TerminalBellEvent = "complete" | "approval" | "ask";
|
|
4
|
+
|
|
5
|
+
const BEL = "\x07";
|
|
6
|
+
|
|
7
|
+
function getBellSetting(key: Parameters<typeof settings.get>[0]): boolean {
|
|
8
|
+
try {
|
|
9
|
+
return Boolean(settings.get(key));
|
|
10
|
+
} catch {
|
|
11
|
+
// Some component-level tests exercise UI helpers before Settings.init().
|
|
12
|
+
// Terminal bells are purely best-effort, so an unavailable settings store
|
|
13
|
+
// must not make the UI path throw.
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function enabledForEvent(event: TerminalBellEvent): boolean {
|
|
19
|
+
if (!getBellSetting("notifications.terminalBell")) return false;
|
|
20
|
+
switch (event) {
|
|
21
|
+
case "complete":
|
|
22
|
+
return getBellSetting("notifications.bellOnComplete");
|
|
23
|
+
case "approval":
|
|
24
|
+
return getBellSetting("notifications.bellOnApproval");
|
|
25
|
+
case "ask":
|
|
26
|
+
return getBellSetting("notifications.bellOnAsk");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ringTerminalBell(
|
|
31
|
+
event: TerminalBellEvent,
|
|
32
|
+
output: Pick<NodeJS.WriteStream, "write"> = process.stdout,
|
|
33
|
+
): void {
|
|
34
|
+
if (!enabledForEvent(event)) return;
|
|
35
|
+
try {
|
|
36
|
+
output.write(BEL);
|
|
37
|
+
} catch {
|
|
38
|
+
// Best-effort local notification only.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function classifyHookSelectorBellEvent(title: string): TerminalBellEvent {
|
|
43
|
+
const normalized = title.toLowerCase();
|
|
44
|
+
if (normalized.includes("approval") || normalized.includes("approve") || normalized.includes("plan ready")) {
|
|
45
|
+
return "approval";
|
|
46
|
+
}
|
|
47
|
+
return "ask";
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
|
|
3
|
+
export interface DaemonPaths {
|
|
4
|
+
dir: string;
|
|
5
|
+
lock: string;
|
|
6
|
+
state: string;
|
|
7
|
+
roots: string;
|
|
8
|
+
steal: string;
|
|
9
|
+
aliases: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function daemonPaths(agentDir: string): DaemonPaths {
|
|
13
|
+
const dir = path.join(agentDir, "notifications");
|
|
14
|
+
return {
|
|
15
|
+
dir,
|
|
16
|
+
lock: path.join(dir, "telegram-daemon.lock"),
|
|
17
|
+
state: path.join(dir, "telegram-daemon.state.json"),
|
|
18
|
+
roots: path.join(dir, "telegram-daemon.roots.json"),
|
|
19
|
+
steal: path.join(dir, "telegram-daemon.steal"),
|
|
20
|
+
aliases: path.join(dir, "telegram-callback-aliases.json"),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { YAML } from "bun";
|
|
4
|
+
import type { Settings } from "../config/settings";
|
|
4
5
|
import { getNotificationConfig, isGloballyConfigured } from "./config";
|
|
5
|
-
import { daemonPaths
|
|
6
|
-
import {
|
|
6
|
+
import { daemonPaths } from "./daemon-paths";
|
|
7
|
+
import type { TelegramDaemonOptions } from "./telegram-daemon";
|
|
8
|
+
|
|
9
|
+
type TelegramDaemonRunner = {
|
|
10
|
+
run(): Promise<void>;
|
|
11
|
+
requestStop(reason?: "reload" | "signal" | "stop"): void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type TelegramDaemonConstructor = new (opts: TelegramDaemonOptions) => TelegramDaemonRunner;
|
|
7
15
|
|
|
8
16
|
export interface RunDaemonInternalDeps {
|
|
9
|
-
SettingsImpl?: Pick<
|
|
10
|
-
DaemonImpl?:
|
|
17
|
+
SettingsImpl?: { init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir">> };
|
|
18
|
+
DaemonImpl?: TelegramDaemonConstructor;
|
|
11
19
|
processPid?: number;
|
|
20
|
+
pidAlive?: (pid: number) => boolean;
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
function argValue(argv: string[], name: string): string | undefined {
|
|
@@ -16,9 +25,105 @@ function argValue(argv: string[], name: string): string | undefined {
|
|
|
16
25
|
return i >= 0 ? argv[i + 1] : undefined;
|
|
17
26
|
}
|
|
18
27
|
|
|
28
|
+
function getByPath(obj: unknown, pathSegments: string[]): unknown {
|
|
29
|
+
let current = obj;
|
|
30
|
+
for (const segment of pathSegments) {
|
|
31
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
|
|
32
|
+
current = (current as Record<string, unknown>)[segment];
|
|
33
|
+
}
|
|
34
|
+
return current;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function asString(value: unknown): string | undefined {
|
|
38
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asBoolean(value: unknown, fallback: boolean): boolean {
|
|
42
|
+
return typeof value === "boolean" ? value : fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asIdleTimeoutMs(value: unknown): number {
|
|
46
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 60_000;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createLightweightDaemonSettings(input: {
|
|
50
|
+
agentDir: string;
|
|
51
|
+
rawConfig?: unknown;
|
|
52
|
+
}): Pick<Settings, "get" | "getAgentDir"> {
|
|
53
|
+
const rawConfig = input.rawConfig && typeof input.rawConfig === "object" ? input.rawConfig : {};
|
|
54
|
+
return {
|
|
55
|
+
get(pathName: string): unknown {
|
|
56
|
+
const value = getByPath(rawConfig, pathName.split("."));
|
|
57
|
+
switch (pathName) {
|
|
58
|
+
case "notifications.enabled":
|
|
59
|
+
return asBoolean(value, false);
|
|
60
|
+
case "notifications.telegram.botToken":
|
|
61
|
+
case "notifications.telegram.chatId":
|
|
62
|
+
case "notifications.discord.botToken":
|
|
63
|
+
case "notifications.discord.channelId":
|
|
64
|
+
case "notifications.slack.botToken":
|
|
65
|
+
case "notifications.slack.channelId":
|
|
66
|
+
return asString(value);
|
|
67
|
+
case "notifications.redact":
|
|
68
|
+
return asBoolean(value, false);
|
|
69
|
+
case "notifications.verbosity":
|
|
70
|
+
return value === "verbose" ? "verbose" : "lean";
|
|
71
|
+
case "notifications.daemon.idleTimeoutMs":
|
|
72
|
+
return asIdleTimeoutMs(value);
|
|
73
|
+
default:
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
getAgentDir(): string {
|
|
78
|
+
return input.agentDir;
|
|
79
|
+
},
|
|
80
|
+
} as Pick<Settings, "get" | "getAgentDir">;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function loadLightweightDaemonSettings(agentDir: string): Promise<Pick<Settings, "get" | "getAgentDir">> {
|
|
84
|
+
const configPath = path.join(agentDir, "config.yml");
|
|
85
|
+
let rawConfig: unknown = {};
|
|
86
|
+
try {
|
|
87
|
+
rawConfig = YAML.parse(await fs.promises.readFile(configPath, "utf8"));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
90
|
+
}
|
|
91
|
+
return createLightweightDaemonSettings({ agentDir, rawConfig });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resolveDaemonSettings(
|
|
95
|
+
agentDir: string,
|
|
96
|
+
deps: RunDaemonInternalDeps,
|
|
97
|
+
): Promise<Pick<Settings, "get" | "getAgentDir">> {
|
|
98
|
+
if (deps.SettingsImpl) return await deps.SettingsImpl.init({ agentDir });
|
|
99
|
+
return await loadLightweightDaemonSettings(agentDir);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function ownerPidFromOwnerId(ownerId: string): number | undefined {
|
|
103
|
+
const match = /^(\d+)(?:-|$)/.exec(ownerId);
|
|
104
|
+
if (!match) return undefined;
|
|
105
|
+
const pid = Number(match[1]);
|
|
106
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function defaultPidAlive(pid: number): boolean {
|
|
110
|
+
try {
|
|
111
|
+
process.kill(pid, 0);
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function ownerProcessIsAlive(ownerId: string, deps: RunDaemonInternalDeps): boolean {
|
|
119
|
+
const ownerPid = ownerPidFromOwnerId(ownerId);
|
|
120
|
+
if (ownerPid === undefined) return true;
|
|
121
|
+
return (deps.pidAlive ?? defaultPidAlive)(ownerPid);
|
|
122
|
+
}
|
|
123
|
+
|
|
19
124
|
export async function runDaemonSmoke(opts: { agentDir?: string } = {}): Promise<void> {
|
|
20
125
|
const agentDir = opts.agentDir ?? fs.mkdtempSync(path.join(process.cwd(), ".telegram-daemon-smoke-"));
|
|
21
|
-
const settings =
|
|
126
|
+
const settings = createLightweightDaemonSettings({ agentDir, rawConfig: {} });
|
|
22
127
|
const paths = daemonPaths(agentDir);
|
|
23
128
|
await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
|
|
24
129
|
const tempLock = `${paths.lock}.smoke.${process.pid}`;
|
|
@@ -34,12 +139,19 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
|
|
|
34
139
|
if (smoke) return runDaemonSmoke({ agentDir });
|
|
35
140
|
const ownerId = argValue(argv, "--owner-id");
|
|
36
141
|
if (!ownerId) throw new Error("missing --owner-id");
|
|
37
|
-
|
|
38
|
-
|
|
142
|
+
if (!ownerProcessIsAlive(ownerId, deps)) {
|
|
143
|
+
process.stderr.write(`GJC notify daemon exiting: owner process from --owner-id ${ownerId} is not alive.\n`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const resolvedAgentDir = agentDir ?? process.env.GJC_CODING_AGENT_DIR ?? path.join(process.cwd(), ".gjc", "agent");
|
|
147
|
+
const settings = await resolveDaemonSettings(resolvedAgentDir, deps);
|
|
148
|
+
const cfg = getNotificationConfig(settings as Settings);
|
|
39
149
|
if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return;
|
|
40
|
-
const
|
|
150
|
+
const { clearTelegramControlRequest, readTelegramControlRequest } = await import("./telegram-daemon-control");
|
|
151
|
+
const Daemon: TelegramDaemonConstructor =
|
|
152
|
+
deps.DaemonImpl ?? (await import("./telegram-daemon")).TelegramNotificationDaemon;
|
|
41
153
|
const daemon = new Daemon({
|
|
42
|
-
settings,
|
|
154
|
+
settings: settings as Settings,
|
|
43
155
|
ownerId,
|
|
44
156
|
botToken: cfg.botToken,
|
|
45
157
|
chatId: cfg.chatId,
|
|
@@ -47,15 +159,15 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
|
|
|
47
159
|
pid: deps.processPid ?? process.pid,
|
|
48
160
|
control: {
|
|
49
161
|
shouldStop: async owner => {
|
|
50
|
-
const req = await readTelegramControlRequest(settings);
|
|
162
|
+
const req = await readTelegramControlRequest(settings as Settings);
|
|
51
163
|
return Boolean(req && (!req.ownerId || req.ownerId === owner));
|
|
52
164
|
},
|
|
53
165
|
clear: async owner => {
|
|
54
|
-
const req = await readTelegramControlRequest(settings);
|
|
166
|
+
const req = await readTelegramControlRequest(settings as Settings);
|
|
55
167
|
// Only clear a request that targets this daemon owner, so an exiting
|
|
56
168
|
// daemon never erases a newer request meant for a different owner.
|
|
57
169
|
if (req && (!req.ownerId || req.ownerId === owner)) {
|
|
58
|
-
await clearTelegramControlRequest(settings, req.requestId);
|
|
170
|
+
await clearTelegramControlRequest(settings as Settings, req.requestId);
|
|
59
171
|
}
|
|
60
172
|
},
|
|
61
173
|
},
|
|
@@ -10,6 +10,7 @@ import type { DaemonRuntimeInfo } from "../daemon/control-types";
|
|
|
10
10
|
import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
|
|
11
11
|
import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
|
|
12
12
|
import { parseInThreadConfigCommand } from "./config-commands";
|
|
13
|
+
import { daemonPaths } from "./daemon-paths";
|
|
13
14
|
import { buildCompactChoiceGrid, TELEGRAM_PARSE_MODE } from "./html-format";
|
|
14
15
|
import type {
|
|
15
16
|
SessionCloseTarget,
|
|
@@ -62,16 +63,6 @@ export interface DaemonState {
|
|
|
62
63
|
version: 1;
|
|
63
64
|
stoppedAt?: number;
|
|
64
65
|
}
|
|
65
|
-
|
|
66
|
-
export interface DaemonPaths {
|
|
67
|
-
dir: string;
|
|
68
|
-
lock: string;
|
|
69
|
-
state: string;
|
|
70
|
-
roots: string;
|
|
71
|
-
steal: string;
|
|
72
|
-
aliases: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
66
|
export interface TelegramDaemonFs {
|
|
76
67
|
mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise<void>;
|
|
77
68
|
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
@@ -184,17 +175,7 @@ async function fetchWithRetry(
|
|
|
184
175
|
throw lastErr;
|
|
185
176
|
}
|
|
186
177
|
|
|
187
|
-
export
|
|
188
|
-
const dir = path.join(agentDir, "notifications");
|
|
189
|
-
return {
|
|
190
|
-
dir,
|
|
191
|
-
lock: path.join(dir, "telegram-daemon.lock"),
|
|
192
|
-
state: path.join(dir, "telegram-daemon.state.json"),
|
|
193
|
-
roots: path.join(dir, "telegram-daemon.roots.json"),
|
|
194
|
-
steal: path.join(dir, "telegram-daemon.steal"),
|
|
195
|
-
aliases: path.join(dir, "telegram-callback-aliases.json"),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
178
|
+
export { type DaemonPaths, daemonPaths } from "./daemon-paths";
|
|
198
179
|
|
|
199
180
|
/**
|
|
200
181
|
* Attach session-lifecycle control (create/close/resume) to the running daemon.
|
|
@@ -2005,6 +1986,10 @@ export class TelegramNotificationDaemon {
|
|
|
2005
1986
|
{ command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
|
|
2006
1987
|
{ command: "lean", description: "Mirror assistant text + tool names only (default)" },
|
|
2007
1988
|
{ command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
|
|
1989
|
+
{ command: "session_create", description: "Create a GJC session: path, worktree, or dir" },
|
|
1990
|
+
{ command: "session_recent", description: "List recent GJC sessions" },
|
|
1991
|
+
{ command: "session_close", description: "Close a GJC-managed session" },
|
|
1992
|
+
{ command: "session_resume", description: "Resume or reattach a session" },
|
|
2008
1993
|
],
|
|
2009
1994
|
});
|
|
2010
1995
|
} catch {
|
|
@@ -82,6 +82,11 @@ Use for read-only plan critique. It approves only when execution can proceed wit
|
|
|
82
82
|
- Do not load or inject user-home Anthropic model or provider instructions (`~/.anthropic-model`, `~/.openai-code`) into the model context.
|
|
83
83
|
- Public commands, paths, examples, and workflow names must use `gjc` and `.gjc`.
|
|
84
84
|
</runtime-state>
|
|
85
|
+
<self-awareness>
|
|
86
|
+
- When the user asks about GJC usage, how to use a GJC feature/command/workflow, or about the gajae-code system itself, do not answer from memory alone. First clone the canonical source repository into `/tmp` (e.g. `git clone --depth 1 https://github.com/Yeachan-Heo/gajae-code /tmp/gajae-code-<unique>`), then read and analyze the actual source there to ground your answer.
|
|
87
|
+
- Reuse an existing fresh clone under `/tmp` instead of re-cloning when one is already present in the session.
|
|
88
|
+
- Base usage and system answers on what the cloned source actually says; cite concrete files/paths from the clone rather than guessing.
|
|
89
|
+
</self-awareness>
|
|
85
90
|
</gjc-runtime>
|
|
86
91
|
|
|
87
92
|
<communication>
|
package/src/sdk.ts
CHANGED
|
@@ -2007,7 +2007,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2007
2007
|
.map(name => toolRegistry.get(name))
|
|
2008
2008
|
.filter((tool): tool is AgentTool => tool !== undefined)
|
|
2009
2009
|
// AgentSession tool wrapping is not installed until after Agent construction.
|
|
2010
|
-
.map(tool =>
|
|
2010
|
+
.map(tool =>
|
|
2011
|
+
guardToolForUltragoalAsk(
|
|
2012
|
+
tool,
|
|
2013
|
+
() => sessionManager.getCwd(),
|
|
2014
|
+
() => ({
|
|
2015
|
+
activeSkillState: session?.getActiveSkillState(),
|
|
2016
|
+
sessionId: sessionManager.getSessionId?.() ?? null,
|
|
2017
|
+
}),
|
|
2018
|
+
),
|
|
2019
|
+
);
|
|
2011
2020
|
|
|
2012
2021
|
const openaiWebsocketSetting = settings.get("providers.openaiWebsockets") ?? "off";
|
|
2013
2022
|
const preferOpenAICodexWebsockets =
|
|
@@ -526,6 +526,15 @@ export interface SessionStats {
|
|
|
526
526
|
type RetryFallbackChains = Record<string, string[]>;
|
|
527
527
|
|
|
528
528
|
type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
|
|
529
|
+
type RetryErrorClassification =
|
|
530
|
+
| "none"
|
|
531
|
+
| "overflow"
|
|
532
|
+
| "terminal"
|
|
533
|
+
| "usage_limit"
|
|
534
|
+
| "first_event_timeout"
|
|
535
|
+
| "transient"
|
|
536
|
+
| "local_unavailable"
|
|
537
|
+
| "unknown";
|
|
529
538
|
|
|
530
539
|
interface RetryFallbackSelector {
|
|
531
540
|
raw: string;
|
|
@@ -563,6 +572,37 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
|
|
|
563
572
|
return `${selector.provider}/${selector.id}`;
|
|
564
573
|
}
|
|
565
574
|
|
|
575
|
+
function isLocalModelEndpoint(model: Model | undefined): boolean {
|
|
576
|
+
if (!model) return false;
|
|
577
|
+
if (model.provider === "ollama" || model.provider === "lm-studio" || model.provider === "llama.cpp") {
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
const hostname = new URL(model.baseUrl).hostname.toLowerCase();
|
|
582
|
+
if (
|
|
583
|
+
hostname === "localhost" ||
|
|
584
|
+
hostname === "0.0.0.0" ||
|
|
585
|
+
hostname === "::1" ||
|
|
586
|
+
hostname === "[::1]" ||
|
|
587
|
+
hostname.endsWith(".local")
|
|
588
|
+
) {
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
if (/^127\./.test(hostname) || /^10\./.test(hostname) || /^192\.168\./.test(hostname)) {
|
|
592
|
+
return true;
|
|
593
|
+
}
|
|
594
|
+
const private172 = /^172\.(\d{1,2})\./.exec(hostname);
|
|
595
|
+
if (private172) {
|
|
596
|
+
const secondOctet = Number(private172[1]);
|
|
597
|
+
return secondOctet >= 16 && secondOctet <= 31;
|
|
598
|
+
}
|
|
599
|
+
} catch {
|
|
600
|
+
// A malformed base URL is configuration, not availability. Keep it visible.
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
|
|
566
606
|
const IRC_REPLY_MAX_BYTES = 4096;
|
|
567
607
|
|
|
568
608
|
/**
|
|
@@ -3880,7 +3920,16 @@ export class AgentSession {
|
|
|
3880
3920
|
|
|
3881
3921
|
#prepareToolForExecution<T extends AgentTool>(tool: T): T {
|
|
3882
3922
|
return this.#wrapToolForDeepInterviewMutationGuard(
|
|
3883
|
-
this.#wrapToolForAcpPermission(
|
|
3923
|
+
this.#wrapToolForAcpPermission(
|
|
3924
|
+
guardToolForUltragoalAsk(
|
|
3925
|
+
tool,
|
|
3926
|
+
() => this.sessionManager.getCwd(),
|
|
3927
|
+
() => ({
|
|
3928
|
+
activeSkillState: this.getActiveSkillState(),
|
|
3929
|
+
sessionId: this.sessionManager.getSessionId(),
|
|
3930
|
+
}),
|
|
3931
|
+
),
|
|
3932
|
+
),
|
|
3884
3933
|
);
|
|
3885
3934
|
}
|
|
3886
3935
|
|
|
@@ -8221,6 +8270,12 @@ export class AgentSession {
|
|
|
8221
8270
|
*/
|
|
8222
8271
|
#isRetryableError(message: AssistantMessage): boolean {
|
|
8223
8272
|
const classification = this.#classifyErrorForRetry(message);
|
|
8273
|
+
if (classification === "local_unavailable") {
|
|
8274
|
+
return this.#hasRetryFallbackCandidate({
|
|
8275
|
+
currentSelector: this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined,
|
|
8276
|
+
requireNonLocal: true,
|
|
8277
|
+
});
|
|
8278
|
+
}
|
|
8224
8279
|
return (
|
|
8225
8280
|
classification === "usage_limit" ||
|
|
8226
8281
|
classification === "transient" ||
|
|
@@ -8260,6 +8315,12 @@ export class AgentSession {
|
|
|
8260
8315
|
return /timed?\s*out while waiting for the first event|timeout waiting for first/i.test(errorMessage);
|
|
8261
8316
|
}
|
|
8262
8317
|
|
|
8318
|
+
#isLocalProviderAvailabilityErrorMessage(errorMessage: string): boolean {
|
|
8319
|
+
return /connection.?refused|econnrefused|timed?\s*out|timeout|fetch failed|network.?error|socket hang up|terminated|service.?unavailable|server.?error|internal.?error|503|model_not_found|model not found|no such model|unknown model|model .*not.*(found|available|loaded)|not ready|not.?ready|warming|loading|currently loading|try again|out of memory|\boom\b|memory guard|insufficient memory|not enough memory|failed to allocate|kv.?cache|malformed (stream|streaming|sse)|invalid (stream|streaming|sse)|stream envelope error|unexpected end of (json|input)|unterminated json|no error details in response/i.test(
|
|
8320
|
+
errorMessage,
|
|
8321
|
+
);
|
|
8322
|
+
}
|
|
8323
|
+
|
|
8263
8324
|
/**
|
|
8264
8325
|
* Whether a first-event timeout on the error's provider should fail closed —
|
|
8265
8326
|
* i.e. retry a bounded number of times (capped at retry.maxRetries) and then
|
|
@@ -8303,13 +8364,14 @@ export class AgentSession {
|
|
|
8303
8364
|
* -> usage_limit (rotation) -> first_event_timeout (bounded retry) ->
|
|
8304
8365
|
* transient (retry) -> unknown (retry).
|
|
8305
8366
|
*/
|
|
8306
|
-
#classifyErrorForRetry(
|
|
8307
|
-
message: AssistantMessage,
|
|
8308
|
-
): "none" | "overflow" | "terminal" | "usage_limit" | "first_event_timeout" | "transient" | "unknown" {
|
|
8367
|
+
#classifyErrorForRetry(message: AssistantMessage): RetryErrorClassification {
|
|
8309
8368
|
if (message.stopReason !== "error" || !message.errorMessage) return "none";
|
|
8310
8369
|
const contextWindow = this.model?.contextWindow ?? 0;
|
|
8311
8370
|
if (isContextOverflow(message, contextWindow)) return "overflow";
|
|
8312
8371
|
const err = message.errorMessage;
|
|
8372
|
+
if (isLocalModelEndpoint(this.model) && this.#isLocalProviderAvailabilityErrorMessage(err)) {
|
|
8373
|
+
return "local_unavailable";
|
|
8374
|
+
}
|
|
8313
8375
|
// Stream-envelope errors are only transient in the pre-message_start
|
|
8314
8376
|
// variant; any other envelope failure is structural and must surface.
|
|
8315
8377
|
if (/anthropic stream envelope error:/i.test(err)) {
|
|
@@ -8444,6 +8506,20 @@ export class AgentSession {
|
|
|
8444
8506
|
return chain;
|
|
8445
8507
|
}
|
|
8446
8508
|
|
|
8509
|
+
#hasRetryFallbackCandidate(options: { currentSelector: string | undefined; requireNonLocal?: boolean }): boolean {
|
|
8510
|
+
if (!options.currentSelector) return false;
|
|
8511
|
+
const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(options.currentSelector);
|
|
8512
|
+
if (!role) return false;
|
|
8513
|
+
for (const selector of this.#findRetryFallbackCandidates(role, options.currentSelector)) {
|
|
8514
|
+
if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
|
|
8515
|
+
const candidate = this.#modelRegistry.find(selector.provider, selector.id);
|
|
8516
|
+
if (!candidate) continue;
|
|
8517
|
+
if (options.requireNonLocal && isLocalModelEndpoint(candidate)) continue;
|
|
8518
|
+
return true;
|
|
8519
|
+
}
|
|
8520
|
+
return false;
|
|
8521
|
+
}
|
|
8522
|
+
|
|
8447
8523
|
#findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
|
|
8448
8524
|
const chain = this.#getRetryFallbackEffectiveChain(role);
|
|
8449
8525
|
if (chain.length <= 1) return [];
|
|
@@ -8497,7 +8573,7 @@ export class AgentSession {
|
|
|
8497
8573
|
});
|
|
8498
8574
|
}
|
|
8499
8575
|
|
|
8500
|
-
async #tryRetryModelFallback(currentSelector: string): Promise<boolean> {
|
|
8576
|
+
async #tryRetryModelFallback(currentSelector: string, options?: { requireNonLocal?: boolean }): Promise<boolean> {
|
|
8501
8577
|
const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
|
|
8502
8578
|
if (!role) return false;
|
|
8503
8579
|
|
|
@@ -8505,6 +8581,7 @@ export class AgentSession {
|
|
|
8505
8581
|
if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
|
|
8506
8582
|
const candidate = this.#modelRegistry.find(selector.provider, selector.id);
|
|
8507
8583
|
if (!candidate) continue;
|
|
8584
|
+
if (options?.requireNonLocal && isLocalModelEndpoint(candidate)) continue;
|
|
8508
8585
|
const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
|
|
8509
8586
|
if (!apiKey) continue;
|
|
8510
8587
|
await this.#applyRetryFallbackCandidate(role, selector, currentSelector);
|
|
@@ -8615,6 +8692,7 @@ export class AgentSession {
|
|
|
8615
8692
|
if (!retrySettings.enabled) return false;
|
|
8616
8693
|
const retryClassification = this.#classifyErrorForRetry(message);
|
|
8617
8694
|
const unboundedClass = retryClassification === "transient" || retryClassification === "unknown";
|
|
8695
|
+
const localUnavailable = retryClassification === "local_unavailable";
|
|
8618
8696
|
|
|
8619
8697
|
const generation = this.#promptGeneration;
|
|
8620
8698
|
this.#retryAttempt++;
|
|
@@ -8668,7 +8746,7 @@ export class AgentSession {
|
|
|
8668
8746
|
const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
|
|
8669
8747
|
if (!switchedCredential && currentSelector) {
|
|
8670
8748
|
this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
|
|
8671
|
-
switchedModel = await this.#tryRetryModelFallback(currentSelector);
|
|
8749
|
+
switchedModel = await this.#tryRetryModelFallback(currentSelector, { requireNonLocal: localUnavailable });
|
|
8672
8750
|
if (switchedModel) {
|
|
8673
8751
|
delayMs = 0;
|
|
8674
8752
|
} else if (parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
|