@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +14 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/update-cli.d.ts +24 -1
  6. package/dist/types/commands/local-provider.d.ts +34 -0
  7. package/dist/types/config/keybindings.d.ts +1 -1
  8. package/dist/types/config/model-profile-activation.d.ts +19 -2
  9. package/dist/types/config/model-profiles.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +9 -0
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +45 -0
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  15. package/dist/types/gjc-runtime/launch-tmux.d.ts +14 -5
  16. package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
  17. package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
  18. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +3 -1
  19. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  20. package/dist/types/modes/components/hook-input.d.ts +1 -0
  21. package/dist/types/modes/components/model-selector.d.ts +11 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +4 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  24. package/dist/types/modes/components/status-line.d.ts +7 -3
  25. package/dist/types/modes/controllers/extension-ui-controller.d.ts +3 -1
  26. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  27. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  28. package/dist/types/modes/interactive-mode.d.ts +3 -1
  29. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  30. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +21 -1
  31. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  32. package/dist/types/modes/types.d.ts +3 -1
  33. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  34. package/dist/types/notifications/daemon-paths.d.ts +9 -0
  35. package/dist/types/notifications/telegram-daemon-cli.d.ts +21 -4
  36. package/dist/types/notifications/telegram-daemon.d.ts +1 -10
  37. package/dist/types/tools/ask.d.ts +24 -0
  38. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  39. package/package.json +7 -7
  40. package/src/cli/local-provider-smoke.ts +632 -0
  41. package/src/cli/notify-cli.ts +5 -3
  42. package/src/cli/update-cli.ts +155 -13
  43. package/src/cli.ts +19 -0
  44. package/src/commands/launch.ts +46 -1
  45. package/src/commands/local-provider.ts +62 -0
  46. package/src/commands/notify.ts +1 -1
  47. package/src/config/keybindings.ts +1 -1
  48. package/src/config/model-profile-activation.ts +92 -11
  49. package/src/config/model-profiles.ts +15 -2
  50. package/src/config/model-registry.ts +136 -6
  51. package/src/config/models-config-schema.ts +9 -0
  52. package/src/config/settings-schema.ts +48 -0
  53. package/src/config/settings.ts +11 -0
  54. package/src/coordinator-mcp/server.ts +280 -36
  55. package/src/deep-interview/render-middleware.ts +1 -0
  56. package/src/defaults/gjc/skills/deep-interview/SKILL.md +14 -6
  57. package/src/defaults/gjc/skills/ralplan/SKILL.md +14 -7
  58. package/src/gjc-runtime/deep-interview-runtime.ts +1 -1
  59. package/src/gjc-runtime/launch-tmux.ts +321 -38
  60. package/src/gjc-runtime/launch-worktree.ts +16 -2
  61. package/src/gjc-runtime/team-runtime.ts +87 -7
  62. package/src/gjc-runtime/tmux-common.ts +16 -0
  63. package/src/gjc-runtime/tmux-sessions.ts +13 -9
  64. package/src/gjc-runtime/ultragoal-guard.ts +11 -5
  65. package/src/internal-urls/docs-index.generated.ts +10 -9
  66. package/src/modes/components/agent-dashboard.ts +2 -1
  67. package/src/modes/components/custom-model-preset-wizard.ts +31 -192
  68. package/src/modes/components/hook-editor.ts +7 -4
  69. package/src/modes/components/hook-input.ts +4 -0
  70. package/src/modes/components/model-selector.ts +260 -12
  71. package/src/modes/components/session-observer-overlay.ts +3 -3
  72. package/src/modes/components/settings-selector.ts +506 -5
  73. package/src/modes/components/status-line/segments.ts +16 -19
  74. package/src/modes/components/status-line/types.ts +6 -2
  75. package/src/modes/components/status-line.ts +70 -35
  76. package/src/modes/controllers/event-controller.ts +98 -4
  77. package/src/modes/controllers/extension-ui-controller.ts +5 -0
  78. package/src/modes/controllers/input-controller.ts +15 -0
  79. package/src/modes/controllers/selector-controller.ts +142 -11
  80. package/src/modes/interactive-mode.ts +7 -2
  81. package/src/modes/prompt-action-autocomplete.ts +8 -0
  82. package/src/modes/shared/agent-wire/deep-interview-gate.ts +11 -4
  83. package/src/modes/tmux-scroll.ts +92 -0
  84. package/src/modes/types.ts +6 -1
  85. package/src/modes/utils/terminal-bell.ts +48 -0
  86. package/src/notifications/daemon-paths.ts +22 -0
  87. package/src/notifications/telegram-daemon-cli.ts +125 -13
  88. package/src/notifications/telegram-daemon.ts +6 -21
  89. package/src/prompts/system/system-prompt.md +5 -0
  90. package/src/sdk.ts +10 -1
  91. package/src/session/agent-session.ts +84 -6
  92. package/src/slash-commands/builtin-registry.ts +106 -13
  93. package/src/tools/ask.ts +11 -1
  94. package/src/tools/eval.ts +2 -2
  95. package/src/tools/render-utils.ts +2 -0
  96. 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 deep-interview question. */
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
+ }
@@ -290,7 +290,12 @@ export interface InteractiveModeContext {
290
290
  dialogOptions?: ExtensionUIDialogOptions,
291
291
  ): Promise<string | undefined>;
292
292
  hideHookSelector(): void;
293
- showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
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 { Settings } from "../config/settings";
3
+ import { YAML } from "bun";
4
+ import type { Settings } from "../config/settings";
4
5
  import { getNotificationConfig, isGloballyConfigured } from "./config";
5
- import { daemonPaths, TelegramNotificationDaemon } from "./telegram-daemon";
6
- import { clearTelegramControlRequest, readTelegramControlRequest } from "./telegram-daemon-control";
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<typeof Settings, "init">;
10
- DaemonImpl?: typeof TelegramNotificationDaemon;
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 = Settings.isolated({});
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
- const settings = await (deps.SettingsImpl ?? Settings).init(agentDir ? { agentDir } : {});
38
- const cfg = getNotificationConfig(settings);
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 Daemon = deps.DaemonImpl ?? TelegramNotificationDaemon;
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 function daemonPaths(agentDir: string): DaemonPaths {
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 => guardToolForUltragoalAsk(tool, () => sessionManager.getCwd()));
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(guardToolForUltragoalAsk(tool, () => this.sessionManager.getCwd())),
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) {