@gajae-code/coding-agent 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/dist/types/async/job-manager.d.ts +7 -0
- package/dist/types/cli/args.d.ts +3 -1
- package/dist/types/commands/deep-interview.d.ts +3 -0
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/model-profile-activation.d.ts +30 -0
- package/dist/types/config/model-profiles.d.ts +19 -0
- package/dist/types/config/model-registry.d.ts +8 -0
- package/dist/types/config/model-resolver.d.ts +1 -1
- package/dist/types/config/models-config-schema.d.ts +47 -0
- package/dist/types/config/settings-schema.d.ts +14 -4
- package/dist/types/debug/crash-diagnostics.d.ts +45 -0
- package/dist/types/debug/runtime-gauges.d.ts +6 -0
- package/dist/types/deep-interview/render-middleware.d.ts +1 -0
- package/dist/types/eval/py/executor.d.ts +2 -0
- package/dist/types/eval/py/kernel.d.ts +2 -0
- package/dist/types/exec/bash-executor.d.ts +10 -0
- package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
- package/dist/types/gjc-runtime/state-migrations.d.ts +9 -0
- package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -1
- package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
- package/dist/types/harness-control-plane/control-endpoint.d.ts +3 -2
- package/dist/types/hooks/skill-state.d.ts +21 -0
- package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
- package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
- package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
- package/dist/types/internal-urls/types.d.ts +4 -0
- package/dist/types/lsp/index.d.ts +10 -10
- package/dist/types/main.d.ts +10 -1
- package/dist/types/modes/bridge/auth.d.ts +12 -0
- package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
- package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
- package/dist/types/modes/bridge/event-stream.d.ts +8 -0
- package/dist/types/modes/components/custom-editor.d.ts +6 -0
- package/dist/types/modes/components/custom-provider-wizard.d.ts +10 -0
- package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
- package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
- package/dist/types/modes/components/model-selector.d.ts +6 -1
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +9 -0
- package/dist/types/modes/index.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/jobs-observer.d.ts +57 -0
- package/dist/types/modes/rpc/host-tools.d.ts +1 -16
- package/dist/types/modes/rpc/host-uris.d.ts +1 -38
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
- package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
- package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
- package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
- package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
- package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
- package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
- package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +11 -1
- package/dist/types/skill-state/workflow-state-contract.d.ts +1 -2
- package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/id.d.ts +7 -0
- package/dist/types/task/index.d.ts +5 -0
- package/dist/types/task/receipt.d.ts +85 -0
- package/dist/types/task/spawn-gate.d.ts +38 -0
- package/dist/types/task/types.d.ts +143 -11
- package/dist/types/tools/cron.d.ts +6 -0
- package/dist/types/tools/hindsight-recall.d.ts +0 -2
- package/dist/types/tools/hindsight-reflect.d.ts +0 -2
- package/dist/types/tools/hindsight-retain.d.ts +0 -2
- package/dist/types/tools/index.d.ts +6 -4
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +15 -0
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/async/job-manager.ts +36 -0
- package/src/cli/args.ts +19 -2
- package/src/commands/deep-interview.ts +1 -0
- package/src/commands/harness.ts +289 -19
- package/src/commands/launch.ts +10 -2
- package/src/commands/state.ts +2 -1
- package/src/commands/team.ts +22 -4
- package/src/config/keybindings.ts +6 -0
- package/src/config/model-profile-activation.ts +157 -0
- package/src/config/model-profiles.ts +155 -0
- package/src/config/model-registry.ts +19 -0
- package/src/config/model-resolver.ts +3 -2
- package/src/config/models-config-schema.ts +36 -0
- package/src/config/settings-schema.ts +16 -3
- package/src/dap/client.ts +17 -3
- package/src/debug/crash-diagnostics.ts +223 -0
- package/src/debug/runtime-gauges.ts +20 -0
- package/src/deep-interview/render-middleware.ts +6 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -3
- package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +61 -0
- package/src/defaults/gjc-defaults.ts +7 -0
- package/src/eval/py/executor.ts +21 -1
- package/src/eval/py/kernel.ts +15 -0
- package/src/exec/bash-executor.ts +41 -0
- package/src/gjc-runtime/cli-write-receipt.ts +31 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +69 -32
- package/src/gjc-runtime/ralplan-runtime.ts +213 -36
- package/src/gjc-runtime/state-migrations.ts +54 -7
- package/src/gjc-runtime/state-runtime.ts +461 -64
- package/src/gjc-runtime/state-schema.ts +192 -0
- package/src/gjc-runtime/state-writer.ts +32 -1
- package/src/gjc-runtime/team-runtime.ts +177 -105
- package/src/gjc-runtime/ultragoal-runtime.ts +231 -38
- package/src/gjc-runtime/workflow-command-ref.ts +239 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +108 -4
- package/src/gjc-runtime/workflow-manifest.ts +3 -1
- package/src/harness-control-plane/control-endpoint.ts +19 -8
- package/src/harness-control-plane/owner.ts +57 -10
- package/src/harness-control-plane/state-machine.ts +2 -1
- package/src/hooks/skill-state.ts +176 -26
- package/src/internal-urls/agent-protocol.ts +68 -21
- package/src/internal-urls/artifact-protocol.ts +12 -17
- package/src/internal-urls/docs-index.generated.ts +8 -10
- package/src/internal-urls/registry-helpers.ts +19 -16
- package/src/internal-urls/types.ts +4 -0
- package/src/lsp/client.ts +18 -2
- package/src/main.ts +88 -6
- package/src/modes/bridge/auth.ts +41 -0
- package/src/modes/bridge/bridge-client-bridge.ts +47 -0
- package/src/modes/bridge/bridge-mode.ts +520 -0
- package/src/modes/bridge/bridge-ui-context.ts +200 -0
- package/src/modes/bridge/event-stream.ts +70 -0
- package/src/modes/components/custom-editor.ts +101 -0
- package/src/modes/components/custom-provider-wizard.ts +318 -0
- package/src/modes/components/hook-selector.ts +61 -18
- package/src/modes/components/jobs-overlay-model.ts +109 -0
- package/src/modes/components/jobs-overlay.ts +172 -0
- package/src/modes/components/model-selector.ts +108 -18
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/components/status-line/presets.ts +7 -5
- package/src/modes/components/status-line/segments.ts +25 -0
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/status-line.ts +9 -1
- package/src/modes/controllers/extension-ui-controller.ts +39 -3
- package/src/modes/controllers/input-controller.ts +97 -9
- package/src/modes/controllers/selector-controller.ts +86 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +27 -0
- package/src/modes/jobs-observer.ts +204 -0
- package/src/modes/rpc/host-tools.ts +1 -186
- package/src/modes/rpc/host-uris.ts +1 -235
- package/src/modes/rpc/rpc-client.ts +25 -10
- package/src/modes/rpc/rpc-mode.ts +12 -381
- package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
- package/src/modes/shared/agent-wire/command-validation.ts +131 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
- package/src/modes/shared/agent-wire/handshake.ts +117 -0
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
- package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
- package/src/modes/shared/agent-wire/protocol.ts +96 -0
- package/src/modes/shared/agent-wire/responses.ts +17 -0
- package/src/modes/shared/agent-wire/scopes.ts +89 -0
- package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
- package/src/modes/shared/agent-wire/ui-result.ts +48 -0
- package/src/modes/types.ts +2 -0
- package/src/prompts/memories/consolidation.md +1 -1
- package/src/prompts/memories/read-path.md +6 -7
- package/src/prompts/memories/unavailable.md +2 -2
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/irc.md +1 -1
- package/src/prompts/tools/read.md +2 -2
- package/src/prompts/tools/recall.md +1 -0
- package/src/prompts/tools/reflect.md +1 -0
- package/src/prompts/tools/retain.md +1 -0
- package/src/prompts/tools/subagent.md +12 -7
- package/src/prompts/tools/task-summary.md +3 -9
- package/src/prompts/tools/task.md +5 -1
- package/src/sdk.ts +5 -1
- package/src/session/agent-session.ts +214 -38
- package/src/skill-state/deep-interview-mutation-guard.ts +23 -4
- package/src/skill-state/workflow-state-contract.ts +7 -4
- package/src/skill-state/workflow-state-version.ts +3 -0
- package/src/slash-commands/builtin-registry.ts +9 -1
- package/src/task/executor.ts +31 -5
- package/src/task/id.ts +33 -0
- package/src/task/index.ts +259 -67
- package/src/task/output-manager.ts +5 -4
- package/src/task/receipt.ts +297 -0
- package/src/task/render.ts +48 -131
- package/src/task/spawn-gate.ts +132 -0
- package/src/task/types.ts +48 -7
- package/src/tools/ask.ts +73 -33
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash.ts +1 -1
- package/src/tools/cron.ts +48 -0
- package/src/tools/find.ts +4 -1
- package/src/tools/hindsight-recall.ts +0 -2
- package/src/tools/hindsight-reflect.ts +0 -2
- package/src/tools/hindsight-retain.ts +0 -2
- package/src/tools/index.ts +6 -18
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +4 -3
- package/src/tools/search.ts +1 -0
- package/src/tools/skill.ts +6 -1
- package/src/tools/subagent.ts +237 -84
|
@@ -25,11 +25,17 @@ import type { InteractiveModeContext } from "../../modes/types";
|
|
|
25
25
|
import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
|
|
26
26
|
|
|
27
27
|
const MAX_WIDGET_LINES = 10;
|
|
28
|
+
const HOOK_SELECTOR_MOUSE_REPORTING_ENABLE = "\x1b[?1006h\x1b[?1000h";
|
|
29
|
+
const HOOK_SELECTOR_MOUSE_REPORTING_DISABLE = "\x1b[?1000l\x1b[?1006l";
|
|
30
|
+
const HOOK_SELECTOR_CHROME_ROWS = 7;
|
|
31
|
+
const HOOK_SELECTOR_OUTLINE_ROWS = 2;
|
|
28
32
|
|
|
29
33
|
export class ExtensionUiController {
|
|
30
34
|
#extensionTerminalInputUnsubscribers = new Set<() => void>();
|
|
31
35
|
#hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
|
|
32
36
|
#hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
|
|
37
|
+
#hookSelectorMouseReportingEnabled = false;
|
|
38
|
+
|
|
33
39
|
constructor(private ctx: InteractiveModeContext) {}
|
|
34
40
|
|
|
35
41
|
/**
|
|
@@ -589,12 +595,20 @@ export class ExtensionUiController {
|
|
|
589
595
|
() => this.hideHookSelector(),
|
|
590
596
|
dialogOptions?.signal,
|
|
591
597
|
);
|
|
592
|
-
const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
|
|
593
598
|
const requestedTitleRows = dialogOptions?.scrollTitleRows;
|
|
594
|
-
const
|
|
595
|
-
const
|
|
599
|
+
const baseMaxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
|
|
600
|
+
const scrollOptionRows = Math.max(1, Math.min(baseMaxVisible, options.length));
|
|
601
|
+
const maxVisible =
|
|
602
|
+
requestedTitleRows === undefined ? baseMaxVisible : Math.min(15, Math.max(3, scrollOptionRows + 1));
|
|
603
|
+
const listChromeRows = dialogOptions?.outline === true ? HOOK_SELECTOR_OUTLINE_ROWS : 0;
|
|
604
|
+
const availableTitleRows =
|
|
605
|
+
this.ctx.ui.terminal.rows - scrollOptionRows - listChromeRows - HOOK_SELECTOR_CHROME_ROWS;
|
|
596
606
|
const scrollTitleRows =
|
|
597
607
|
requestedTitleRows === undefined ? undefined : Math.max(1, Math.min(requestedTitleRows, availableTitleRows));
|
|
608
|
+
if (scrollTitleRows !== undefined) {
|
|
609
|
+
this.#enableHookSelectorMouseReporting();
|
|
610
|
+
}
|
|
611
|
+
|
|
598
612
|
this.ctx.hookSelector = new HookSelectorComponent(
|
|
599
613
|
title,
|
|
600
614
|
options,
|
|
@@ -640,10 +654,32 @@ export class ExtensionUiController {
|
|
|
640
654
|
attachAbort();
|
|
641
655
|
return promise;
|
|
642
656
|
}
|
|
657
|
+
|
|
658
|
+
#enableHookSelectorMouseReporting(): void {
|
|
659
|
+
if (this.#hookSelectorMouseReportingEnabled) return;
|
|
660
|
+
this.#hookSelectorMouseReportingEnabled = true;
|
|
661
|
+
this.#writeTerminalControl(HOOK_SELECTOR_MOUSE_REPORTING_ENABLE);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
#disableHookSelectorMouseReporting(): void {
|
|
665
|
+
if (!this.#hookSelectorMouseReportingEnabled) return;
|
|
666
|
+
this.#hookSelectorMouseReportingEnabled = false;
|
|
667
|
+
this.#writeTerminalControl(HOOK_SELECTOR_MOUSE_REPORTING_DISABLE);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#writeTerminalControl(sequence: string): void {
|
|
671
|
+
try {
|
|
672
|
+
this.ctx.ui.terminal.write(sequence);
|
|
673
|
+
} catch {
|
|
674
|
+
// Terminal teardown can race selector cleanup; normal shutdown restores modes.
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
643
678
|
/**
|
|
644
679
|
* Hide the hook selector.
|
|
645
680
|
*/
|
|
646
681
|
hideHookSelector(): void {
|
|
682
|
+
this.#disableHookSelectorMouseReporting();
|
|
647
683
|
this.ctx.hookSelector?.dispose();
|
|
648
684
|
this.ctx.editorContainer.clear();
|
|
649
685
|
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import { type AgentMessage, ThinkingLevel } from "@gajae-code/agent-core";
|
|
3
4
|
import type { AutocompleteProvider, SlashCommand } from "@gajae-code/tui";
|
|
4
5
|
import { $env, sanitizeText } from "@gajae-code/utils";
|
|
@@ -13,7 +14,7 @@ import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../sessio
|
|
|
13
14
|
import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
|
|
14
15
|
import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
|
|
15
16
|
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
|
16
|
-
import { ensureSupportedImageInput } from "../../utils/image-loading";
|
|
17
|
+
import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
|
|
17
18
|
import { resizeImage } from "../../utils/image-resize";
|
|
18
19
|
import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
|
|
19
20
|
|
|
@@ -22,6 +23,8 @@ interface Expandable {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS = 5_000;
|
|
26
|
+
const CLIPBOARD_TEMP_IMAGE_FILE_PATTERN = /^clipboard-\d{4}-\d{2}-\d{2}-\d{6}-[A-Za-z0-9]+\.(?:png|jpe?g|gif|webp)$/i;
|
|
27
|
+
const MACOS_CLIPBOARD_TEMP_DIR_PATTERN = /^\/var\/folders\/[^/]+\/[^/]+\/T$/;
|
|
25
28
|
|
|
26
29
|
function isExpandable(obj: unknown): obj is Expandable {
|
|
27
30
|
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
|
|
@@ -30,8 +33,17 @@ function isExpandable(obj: unknown): obj is Expandable {
|
|
|
30
33
|
export class InputController {
|
|
31
34
|
constructor(private ctx: InteractiveModeContext) {}
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
/** Set after a first Esc silently consumes a queued steer. Kept until the
|
|
37
|
+
* queued steer is either cancelled by a second Esc or drained by continuation,
|
|
38
|
+
* so abort cleanup going idle cannot turn the second Esc into an idle action. */
|
|
39
|
+
#steerConsumePending = false;
|
|
40
|
+
|
|
41
|
+
#abortInteractive(options?: { silent?: boolean }): Promise<void> {
|
|
42
|
+
return this.ctx.session.abort({
|
|
43
|
+
timeoutMs: INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS,
|
|
44
|
+
cause: "user_interrupt",
|
|
45
|
+
silent: options?.silent,
|
|
46
|
+
});
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
setupKeyHandlers(): void {
|
|
@@ -40,6 +52,7 @@ export class InputController {
|
|
|
40
52
|
Boolean(
|
|
41
53
|
this.ctx.loadingAnimation ||
|
|
42
54
|
this.ctx.hasActiveBtw() ||
|
|
55
|
+
(this.#steerConsumePending && this.ctx.session.hasQueuedSteering) ||
|
|
43
56
|
this.ctx.session.isStreaming ||
|
|
44
57
|
this.ctx.session.isCompacting ||
|
|
45
58
|
this.ctx.session.isGeneratingHandoff ||
|
|
@@ -54,6 +67,17 @@ export class InputController {
|
|
|
54
67
|
if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
|
|
55
68
|
return;
|
|
56
69
|
}
|
|
70
|
+
if (this.#steerConsumePending) {
|
|
71
|
+
if (this.ctx.session.hasQueuedSteering) {
|
|
72
|
+
// Second Esc before the scheduled steer continuation drains the
|
|
73
|
+
// queue: restore/drop the queued steer and perform a real abort,
|
|
74
|
+
// even if abort cleanup already made the session look idle.
|
|
75
|
+
this.#steerConsumePending = false;
|
|
76
|
+
this.restoreQueuedMessagesToEditor({ abort: true });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.#steerConsumePending = false;
|
|
80
|
+
}
|
|
57
81
|
if (this.ctx.loadingAnimation) {
|
|
58
82
|
if (this.ctx.cancelPendingSubmission()) {
|
|
59
83
|
return;
|
|
@@ -73,7 +97,15 @@ export class InputController {
|
|
|
73
97
|
this.ctx.isPythonMode = false;
|
|
74
98
|
this.ctx.updateEditorBorderColor();
|
|
75
99
|
} else if (this.ctx.session.isStreaming) {
|
|
76
|
-
|
|
100
|
+
if (this.ctx.session.hasQueuedSteering && !this.#steerConsumePending) {
|
|
101
|
+
// First Esc with a queued steer: silently consume it and
|
|
102
|
+
// auto-continue via steer-on-interrupt instead of stalling on
|
|
103
|
+
// "Operation aborted".
|
|
104
|
+
this.#steerConsumePending = true;
|
|
105
|
+
void this.#abortInteractive({ silent: true });
|
|
106
|
+
} else {
|
|
107
|
+
void this.#abortInteractive();
|
|
108
|
+
}
|
|
77
109
|
} else if (!this.ctx.editor.getText().trim()) {
|
|
78
110
|
// Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
|
|
79
111
|
const action = settings.get("doubleEscapeAction");
|
|
@@ -132,6 +164,13 @@ export class InputController {
|
|
|
132
164
|
this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
|
|
133
165
|
);
|
|
134
166
|
this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
|
|
167
|
+
this.ctx.editor.onPasteText = text => this.handleTextPaste(text);
|
|
168
|
+
this.ctx.editor.onPastePendingInputCleared = (reason, droppedInputCount) => {
|
|
169
|
+
const reasonText = reason === "timeout" ? "timed out" : "exceeded the input queue limit";
|
|
170
|
+
this.ctx.showWarning(
|
|
171
|
+
`Paste handling ${reasonText}; discarded ${droppedInputCount} buffered input event${droppedInputCount === 1 ? "" : "s"}.`,
|
|
172
|
+
);
|
|
173
|
+
};
|
|
135
174
|
this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand"));
|
|
136
175
|
this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
|
|
137
176
|
this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue"));
|
|
@@ -170,6 +209,9 @@ export class InputController {
|
|
|
170
209
|
for (const key of this.ctx.keybindings.getKeys("app.session.observe")) {
|
|
171
210
|
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionObserver());
|
|
172
211
|
}
|
|
212
|
+
for (const key of this.ctx.keybindings.getKeys("app.jobs.open")) {
|
|
213
|
+
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showJobsOverlay());
|
|
214
|
+
}
|
|
173
215
|
|
|
174
216
|
this.ctx.editor.onChange = (text: string) => {
|
|
175
217
|
const wasBashMode = this.ctx.isBashMode;
|
|
@@ -602,6 +644,55 @@ export class InputController {
|
|
|
602
644
|
process.kill(0, "SIGTSTP");
|
|
603
645
|
}
|
|
604
646
|
|
|
647
|
+
handleTextPaste(text: string): boolean | Promise<boolean> {
|
|
648
|
+
const imagePath = this.#getPastedImagePathCandidate(text);
|
|
649
|
+
return imagePath ? this.#attachPastedImagePath(imagePath) : false;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async #attachPastedImagePath(imagePath: string): Promise<boolean> {
|
|
653
|
+
try {
|
|
654
|
+
const image = await loadImageInput({
|
|
655
|
+
path: imagePath,
|
|
656
|
+
cwd: this.ctx.sessionManager.getCwd(),
|
|
657
|
+
autoResize: this.ctx.settings.get("images.autoResize"),
|
|
658
|
+
});
|
|
659
|
+
if (!image) {
|
|
660
|
+
this.ctx.showStatus("Unsupported pasted clipboard image file");
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
this.ctx.pendingImages.push({
|
|
665
|
+
type: "image",
|
|
666
|
+
data: image.data,
|
|
667
|
+
mimeType: image.mimeType,
|
|
668
|
+
});
|
|
669
|
+
this.ctx.editor.insertText(`${this.#nextImagePlaceholder()} `);
|
|
670
|
+
this.ctx.showStatus(`Attached image: ${path.basename(image.resolvedPath)}`, { dim: true });
|
|
671
|
+
this.ctx.ui.requestRender();
|
|
672
|
+
return true;
|
|
673
|
+
} catch (error) {
|
|
674
|
+
if (error instanceof ImageInputTooLargeError) {
|
|
675
|
+
this.ctx.showStatus(error.message);
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
this.ctx.showStatus("Failed to attach pasted clipboard image");
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
#getPastedImagePathCandidate(text: string): string | undefined {
|
|
684
|
+
const resolvedPath = path.resolve(text.trim());
|
|
685
|
+
const parentDir = path.dirname(resolvedPath);
|
|
686
|
+
const isClipboardTempPath =
|
|
687
|
+
(parentDir === "/tmp" || MACOS_CLIPBOARD_TEMP_DIR_PATTERN.test(parentDir)) &&
|
|
688
|
+
CLIPBOARD_TEMP_IMAGE_FILE_PATTERN.test(path.basename(resolvedPath));
|
|
689
|
+
return isClipboardTempPath ? resolvedPath : undefined;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
#nextImagePlaceholder(): string {
|
|
693
|
+
return `[image ${this.ctx.pendingImages.length}]`;
|
|
694
|
+
}
|
|
695
|
+
|
|
605
696
|
async handleImagePaste(): Promise<boolean> {
|
|
606
697
|
try {
|
|
607
698
|
const image = await readImageFromClipboard();
|
|
@@ -616,7 +707,7 @@ export class InputController {
|
|
|
616
707
|
this.ctx.showStatus(`Unsupported clipboard image format: ${image.mimeType}`);
|
|
617
708
|
return false;
|
|
618
709
|
}
|
|
619
|
-
if (settings.get("images.autoResize")) {
|
|
710
|
+
if (this.ctx.settings.get("images.autoResize")) {
|
|
620
711
|
try {
|
|
621
712
|
const resized = await resizeImage({
|
|
622
713
|
type: "image",
|
|
@@ -634,10 +725,7 @@ export class InputController {
|
|
|
634
725
|
data: imageData.data,
|
|
635
726
|
mimeType: imageData.mimeType,
|
|
636
727
|
});
|
|
637
|
-
|
|
638
|
-
const imageNum = this.ctx.pendingImages.length;
|
|
639
|
-
const placeholder = `[Image #${imageNum}]`;
|
|
640
|
-
this.ctx.editor.insertText(`${placeholder} `);
|
|
728
|
+
this.ctx.editor.insertText(`${this.#nextImagePlaceholder()} `);
|
|
641
729
|
this.ctx.ui.requestRender();
|
|
642
730
|
return true;
|
|
643
731
|
}
|
|
@@ -4,6 +4,7 @@ import type { OAuthProvider } from "@gajae-code/ai/utils/oauth/types";
|
|
|
4
4
|
import type { Component, OverlayHandle } from "@gajae-code/tui";
|
|
5
5
|
import { Input, Loader, Spacer, Text } from "@gajae-code/tui";
|
|
6
6
|
import { getAgentDbPath, getProjectDir } from "@gajae-code/utils";
|
|
7
|
+
import { activateModelProfile } from "../../config/model-profile-activation";
|
|
7
8
|
import { settings } from "../../config/settings";
|
|
8
9
|
import { DebugSelectorComponent } from "../../debug";
|
|
9
10
|
import { disableProvider, enableProvider } from "../../discovery";
|
|
@@ -35,12 +36,15 @@ import {
|
|
|
35
36
|
MODEL_ONBOARDING_PROVIDER_PRESET_COMMAND,
|
|
36
37
|
MODEL_ONBOARDING_SETUP_COMMAND,
|
|
37
38
|
} from "../../setup/model-onboarding-guidance";
|
|
39
|
+
import { addApiCompatibleProvider, formatProviderSetupResult } from "../../setup/provider-onboarding";
|
|
38
40
|
import { isSearchProviderPreference, setPreferredImageProvider, setPreferredSearchProvider } from "../../tools";
|
|
39
41
|
import { setSessionTerminalTitle } from "../../utils/title-generator";
|
|
40
42
|
import { AgentDashboard } from "../components/agent-dashboard";
|
|
41
43
|
import { AssistantMessageComponent } from "../components/assistant-message";
|
|
44
|
+
import { CustomProviderWizardComponent, type CustomProviderWizardSubmit } from "../components/custom-provider-wizard";
|
|
42
45
|
import { ExtensionDashboard } from "../components/extensions";
|
|
43
46
|
import { HistorySearchComponent } from "../components/history-search";
|
|
47
|
+
import { JobsOverlayComponent } from "../components/jobs-overlay";
|
|
44
48
|
import { ModelSelectorComponent, type ModelSelectorSelection } from "../components/model-selector";
|
|
45
49
|
import { OAuthSelectorComponent } from "../components/oauth-selector";
|
|
46
50
|
import { PluginSelectorComponent } from "../components/plugin-selector";
|
|
@@ -55,6 +59,7 @@ import { ThemeSelectorComponent } from "../components/theme-selector";
|
|
|
55
59
|
import { ToolExecutionComponent } from "../components/tool-execution";
|
|
56
60
|
import { TreeSelectorComponent } from "../components/tree-selector";
|
|
57
61
|
import { UserMessageSelectorComponent } from "../components/user-message-selector";
|
|
62
|
+
import type { JobsObserver } from "../jobs-observer";
|
|
58
63
|
import type { SessionObserverRegistry } from "../session-observer-registry";
|
|
59
64
|
|
|
60
65
|
const CALLBACK_SERVER_PROVIDERS = new Set<string>([
|
|
@@ -117,7 +122,9 @@ export class SelectorController {
|
|
|
117
122
|
const selector = new ProviderOnboardingSelectorComponent(
|
|
118
123
|
(action: ProviderOnboardingAction) => {
|
|
119
124
|
done();
|
|
120
|
-
if (action === "
|
|
125
|
+
if (action === "custom-provider-wizard") {
|
|
126
|
+
this.showCustomProviderWizard();
|
|
127
|
+
} else if (action === "oauth-login") {
|
|
121
128
|
void this.showOAuthSelector("login");
|
|
122
129
|
} else {
|
|
123
130
|
this.ctx.showStatus(formatProviderOnboardingCommandGuide());
|
|
@@ -132,6 +139,36 @@ export class SelectorController {
|
|
|
132
139
|
});
|
|
133
140
|
}
|
|
134
141
|
|
|
142
|
+
showCustomProviderWizard(): void {
|
|
143
|
+
this.showSelector(done => {
|
|
144
|
+
let wizard: CustomProviderWizardComponent;
|
|
145
|
+
const submit = async (input: CustomProviderWizardSubmit): Promise<void> => {
|
|
146
|
+
try {
|
|
147
|
+
const result = await addApiCompatibleProvider(input);
|
|
148
|
+
await this.ctx.session.modelRegistry.refresh("offline");
|
|
149
|
+
await this.ctx.notifyConfigChanged?.();
|
|
150
|
+
this.ctx.showStatus(formatProviderSetupResult(result));
|
|
151
|
+
done();
|
|
152
|
+
this.ctx.ui.requestRender();
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
155
|
+
wizard.setSubmitError(`Provider setup failed: ${message}`);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
wizard = new CustomProviderWizardComponent(
|
|
159
|
+
input => {
|
|
160
|
+
void submit(input);
|
|
161
|
+
},
|
|
162
|
+
() => {
|
|
163
|
+
done();
|
|
164
|
+
this.ctx.ui.requestRender();
|
|
165
|
+
},
|
|
166
|
+
() => this.ctx.ui.requestRender(),
|
|
167
|
+
);
|
|
168
|
+
return { component: wizard, focus: wizard };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
135
172
|
showSettingsSelector(): void {
|
|
136
173
|
getAvailableThemes().then(availableThemes => {
|
|
137
174
|
this.showSelector(done => {
|
|
@@ -500,6 +537,27 @@ export class SelectorController {
|
|
|
500
537
|
this.ctx.ui.requestRender();
|
|
501
538
|
return;
|
|
502
539
|
}
|
|
540
|
+
if (selection.kind === "profile") {
|
|
541
|
+
await activateModelProfile(
|
|
542
|
+
{
|
|
543
|
+
session: this.ctx.session,
|
|
544
|
+
modelRegistry: this.ctx.session.modelRegistry,
|
|
545
|
+
settings: this.ctx.settings,
|
|
546
|
+
profileName: selection.profileName,
|
|
547
|
+
},
|
|
548
|
+
{ persistDefault: selection.setDefault },
|
|
549
|
+
);
|
|
550
|
+
this.ctx.statusLine.invalidate();
|
|
551
|
+
this.ctx.updateEditorBorderColor();
|
|
552
|
+
this.ctx.showStatus(
|
|
553
|
+
selection.setDefault
|
|
554
|
+
? `Default model profile: ${selection.profileName}`
|
|
555
|
+
: `Model profile: ${selection.profileName}`,
|
|
556
|
+
);
|
|
557
|
+
done();
|
|
558
|
+
this.ctx.ui.requestRender();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
503
561
|
const { model, role, thinkingLevel, selector } = selection;
|
|
504
562
|
if (role === null) {
|
|
505
563
|
// Temporary: update agent state but don't persist to settings
|
|
@@ -1150,4 +1208,31 @@ export class SelectorController {
|
|
|
1150
1208
|
this.ctx.ui.setFocus(selector);
|
|
1151
1209
|
this.ctx.ui.requestRender();
|
|
1152
1210
|
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Jobs overlay: navigate ongoing monitor + cron jobs (Monitors then Crons,
|
|
1214
|
+
* newest-first), drill into per-type detail, and cancel/delete with a y/N
|
|
1215
|
+
* confirm. Built from nested SelectLists (list -> detail -> confirm) so focus
|
|
1216
|
+
* stays on the active SelectList.
|
|
1217
|
+
*/
|
|
1218
|
+
showJobsOverlay(observer: JobsObserver): void {
|
|
1219
|
+
let overlay: JobsOverlayComponent | undefined;
|
|
1220
|
+
const close = () => {
|
|
1221
|
+
this.ctx.editorContainer.clear();
|
|
1222
|
+
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
1223
|
+
this.ctx.ui.setFocus(this.ctx.editor);
|
|
1224
|
+
this.ctx.ui.requestRender();
|
|
1225
|
+
};
|
|
1226
|
+
overlay = new JobsOverlayComponent(observer, {
|
|
1227
|
+
close,
|
|
1228
|
+
requestRender: () => {
|
|
1229
|
+
if (overlay) this.ctx.ui.setFocus(overlay.getFocus());
|
|
1230
|
+
this.ctx.ui.requestRender();
|
|
1231
|
+
},
|
|
1232
|
+
});
|
|
1233
|
+
this.ctx.editorContainer.clear();
|
|
1234
|
+
this.ctx.editorContainer.addChild(overlay);
|
|
1235
|
+
this.ctx.ui.setFocus(overlay.getFocus());
|
|
1236
|
+
this.ctx.ui.requestRender();
|
|
1237
|
+
}
|
|
1153
1238
|
}
|
package/src/modes/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { postmortem } from "@gajae-code/utils";
|
|
|
5
5
|
* Run modes for the coding agent.
|
|
6
6
|
*/
|
|
7
7
|
export { runAcpMode } from "./acp";
|
|
8
|
+
export { runBridgeMode } from "./bridge/bridge-mode";
|
|
8
9
|
export { InteractiveMode, type InteractiveModeOptions } from "./interactive-mode";
|
|
9
10
|
export { type PrintModeOptions, runPrintMode } from "./print-mode";
|
|
10
11
|
export {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "@gajae-code/tui";
|
|
29
29
|
import { APP_NAME, adjustHsv, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@gajae-code/utils";
|
|
30
30
|
import chalk from "chalk";
|
|
31
|
+
import { AsyncJobManager } from "../async";
|
|
31
32
|
import { KeybindingsManager } from "../config/keybindings";
|
|
32
33
|
import { isSettingsInitialized, type Settings, settings } from "../config/settings";
|
|
33
34
|
import { DEFAULT_GJC_DEFINITION_NAMES } from "../defaults/gjc-defaults";
|
|
@@ -88,6 +89,7 @@ import { InputController } from "./controllers/input-controller";
|
|
|
88
89
|
import { SelectorController } from "./controllers/selector-controller";
|
|
89
90
|
import { SSHCommandController } from "./controllers/ssh-command-controller";
|
|
90
91
|
import { TodoCommandController } from "./controllers/todo-command-controller";
|
|
92
|
+
import { JobsObserver } from "./jobs-observer";
|
|
91
93
|
import { OAuthManualInputManager } from "./oauth-manual-input";
|
|
92
94
|
import { SessionObserverRegistry } from "./session-observer-registry";
|
|
93
95
|
import { interruptHint } from "./shared";
|
|
@@ -330,6 +332,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
330
332
|
#voicePreviousUseTerminalCursor: boolean | null = null;
|
|
331
333
|
#resizeHandler?: () => void;
|
|
332
334
|
#observerRegistry: SessionObserverRegistry;
|
|
335
|
+
#jobsObserver?: JobsObserver;
|
|
333
336
|
#eventBus?: EventBus;
|
|
334
337
|
#eventBusUnsubscribers: Array<() => void> = [];
|
|
335
338
|
#welcomeComponent?: WelcomeComponent;
|
|
@@ -525,6 +528,19 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
525
528
|
this.ui.requestRender();
|
|
526
529
|
});
|
|
527
530
|
|
|
531
|
+
// Event-driven monitor/cron jobs widget. Scoped to this session's owner so
|
|
532
|
+
// overlay actions cannot mutate another agent's background work.
|
|
533
|
+
const jobManager = AsyncJobManager.instance();
|
|
534
|
+
if (jobManager) {
|
|
535
|
+
const jobsObserver = new JobsObserver(jobManager, this.session.getAgentId());
|
|
536
|
+
this.#jobsObserver = jobsObserver;
|
|
537
|
+
this.statusLine.setJobs(jobsObserver.getSnapshot());
|
|
538
|
+
jobsObserver.onChange(() => {
|
|
539
|
+
this.statusLine.setJobs(jobsObserver.getSnapshot());
|
|
540
|
+
this.ui.requestRender();
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
528
544
|
// Load initial todos
|
|
529
545
|
await this.#loadTodoList();
|
|
530
546
|
|
|
@@ -1843,6 +1859,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1843
1859
|
this.#observerRegistry.dispose();
|
|
1844
1860
|
this.#eventController.dispose();
|
|
1845
1861
|
this.statusLine.dispose();
|
|
1862
|
+
this.#jobsObserver?.dispose();
|
|
1863
|
+
this.editor.dispose();
|
|
1846
1864
|
if (this.#resizeHandler) {
|
|
1847
1865
|
process.stdout.removeListener("resize", this.#resizeHandler);
|
|
1848
1866
|
this.#resizeHandler = undefined;
|
|
@@ -1944,6 +1962,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1944
1962
|
nextEditor.setHistoryStorage(this.historyStorage);
|
|
1945
1963
|
}
|
|
1946
1964
|
nextEditor.setText(previousText);
|
|
1965
|
+
previousEditor.dispose();
|
|
1947
1966
|
|
|
1948
1967
|
this.editorContainer.clear();
|
|
1949
1968
|
this.editor = nextEditor;
|
|
@@ -2317,6 +2336,14 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2317
2336
|
this.#selectorController.showSessionObserver(this.#observerRegistry);
|
|
2318
2337
|
}
|
|
2319
2338
|
|
|
2339
|
+
showJobsOverlay(): void {
|
|
2340
|
+
if (!this.#jobsObserver) {
|
|
2341
|
+
this.showStatus("Background jobs are unavailable in this session");
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
this.#selectorController.showJobsOverlay(this.#jobsObserver);
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2320
2347
|
resetObserverRegistry(): void {
|
|
2321
2348
|
this.#observerRegistry.resetSessions();
|
|
2322
2349
|
this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined);
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JobsObserver
|
|
3
|
+
*
|
|
4
|
+
* Single, event-driven aggregator over the two background-work sources surfaced
|
|
5
|
+
* by the status-line jobs widget and the jobs overlay:
|
|
6
|
+
* - monitor jobs (bash jobs started by the `monitor` tool, tracked in `AsyncJobManager`)
|
|
7
|
+
* - cron jobs (tracked in the cron module's owner-scoped schedule store)
|
|
8
|
+
*
|
|
9
|
+
* It subscribes to change hooks on both sources (no polling), debounces bursts
|
|
10
|
+
* to a microtask, and exposes a precomputed snapshot so the status-line render
|
|
11
|
+
* loop never scans the underlying stores. A failure latch keeps the widget red
|
|
12
|
+
* until `acknowledgeFailures()` is called (when the overlay opens), so a failed
|
|
13
|
+
* job that evicts before the user looks is not silently lost.
|
|
14
|
+
*/
|
|
15
|
+
import type { AsyncJob, AsyncJobManager } from "../async";
|
|
16
|
+
import { deleteCronJobById, listCronSnapshots, onCronChange } from "../tools/cron";
|
|
17
|
+
|
|
18
|
+
export type JobsWorstState = "none" | "running" | "failed";
|
|
19
|
+
|
|
20
|
+
export interface MonitorJobView {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
status: AsyncJob["status"];
|
|
24
|
+
startTime: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CronJobView {
|
|
28
|
+
id: string;
|
|
29
|
+
humanSchedule: string;
|
|
30
|
+
cronExpression: string;
|
|
31
|
+
prompt: string;
|
|
32
|
+
recurring: boolean;
|
|
33
|
+
nextFireAt?: number;
|
|
34
|
+
createdAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface JobsSnapshot {
|
|
38
|
+
monitors: MonitorJobView[];
|
|
39
|
+
crons: CronJobView[];
|
|
40
|
+
activeMonitorCount: number;
|
|
41
|
+
activeCronCount: number;
|
|
42
|
+
worstState: JobsWorstState;
|
|
43
|
+
failedUnacknowledged: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const EMPTY_JOBS_SNAPSHOT: JobsSnapshot = {
|
|
47
|
+
monitors: [],
|
|
48
|
+
crons: [],
|
|
49
|
+
activeMonitorCount: 0,
|
|
50
|
+
activeCronCount: 0,
|
|
51
|
+
worstState: "none",
|
|
52
|
+
failedUnacknowledged: false,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export class JobsObserver {
|
|
56
|
+
readonly #manager: AsyncJobManager;
|
|
57
|
+
readonly #ownerId: string | undefined;
|
|
58
|
+
readonly #unsubscribers: Array<() => void> = [];
|
|
59
|
+
readonly #listeners = new Set<() => void>();
|
|
60
|
+
#failedUnacknowledged = false;
|
|
61
|
+
#notifyScheduled = false;
|
|
62
|
+
#disposed = false;
|
|
63
|
+
#snapshot: JobsSnapshot = EMPTY_JOBS_SNAPSHOT;
|
|
64
|
+
readonly #acknowledgedFailedIds = new Set<string>();
|
|
65
|
+
|
|
66
|
+
constructor(manager: AsyncJobManager, ownerId: string | undefined) {
|
|
67
|
+
this.#manager = manager;
|
|
68
|
+
this.#ownerId = ownerId;
|
|
69
|
+
this.#unsubscribers.push(manager.onChange(() => this.#onUpstreamChange()));
|
|
70
|
+
this.#unsubscribers.push(onCronChange(() => this.#onUpstreamChange()));
|
|
71
|
+
this.#recompute();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Subscribe to debounced change events. Returns an unsubscribe function. */
|
|
75
|
+
onChange(cb: () => void): () => void {
|
|
76
|
+
this.#listeners.add(cb);
|
|
77
|
+
return () => {
|
|
78
|
+
this.#listeners.delete(cb);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#onUpstreamChange(): void {
|
|
83
|
+
if (this.#disposed) return;
|
|
84
|
+
this.#recompute();
|
|
85
|
+
if (this.#notifyScheduled) return;
|
|
86
|
+
this.#notifyScheduled = true;
|
|
87
|
+
queueMicrotask(() => {
|
|
88
|
+
this.#notifyScheduled = false;
|
|
89
|
+
if (this.#disposed) return;
|
|
90
|
+
this.#emit();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#emit(): void {
|
|
95
|
+
for (const cb of this.#listeners) {
|
|
96
|
+
try {
|
|
97
|
+
cb();
|
|
98
|
+
} catch {
|
|
99
|
+
// Listener errors are isolated; a bad subscriber must not break others.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#listMonitorJobs(): AsyncJob[] {
|
|
105
|
+
const filter = this.#ownerId ? { ownerId: this.#ownerId } : undefined;
|
|
106
|
+
return this.#manager.getAllJobs(filter).filter(job => job.type === "bash" && job.metadata?.monitor === true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Recompute and store the snapshot. Called on construction and on every
|
|
111
|
+
* upstream change; the status-line render path only reads the stored
|
|
112
|
+
* snapshot (never scans the manager/cron stores).
|
|
113
|
+
*/
|
|
114
|
+
#recompute(): void {
|
|
115
|
+
const monitorJobs = this.#listMonitorJobs();
|
|
116
|
+
const presentIds = new Set(monitorJobs.map(job => job.id));
|
|
117
|
+
// Prune acknowledged ids whose jobs have been evicted.
|
|
118
|
+
for (const id of this.#acknowledgedFailedIds) {
|
|
119
|
+
if (!presentIds.has(id)) this.#acknowledgedFailedIds.delete(id);
|
|
120
|
+
}
|
|
121
|
+
// Sticky failure latch: set when an unacknowledged failed monitor is seen
|
|
122
|
+
// (including at construction); stays set even after the failed job evicts,
|
|
123
|
+
// until acknowledgeFailures() clears it.
|
|
124
|
+
const hasUnacknowledgedFailure = monitorJobs.some(
|
|
125
|
+
job => job.status === "failed" && !this.#acknowledgedFailedIds.has(job.id),
|
|
126
|
+
);
|
|
127
|
+
if (hasUnacknowledgedFailure) this.#failedUnacknowledged = true;
|
|
128
|
+
|
|
129
|
+
const activeMonitors = monitorJobs.filter(job => job.status === "running");
|
|
130
|
+
const cronSnapshots = listCronSnapshots(this.#ownerId);
|
|
131
|
+
const monitors: MonitorJobView[] = monitorJobs
|
|
132
|
+
.map(job => ({ id: job.id, label: job.label, status: job.status, startTime: job.startTime }))
|
|
133
|
+
.sort((a, b) => b.startTime - a.startTime);
|
|
134
|
+
const crons: CronJobView[] = cronSnapshots
|
|
135
|
+
.map(snapshot => ({
|
|
136
|
+
id: snapshot.id,
|
|
137
|
+
humanSchedule: snapshot.humanSchedule,
|
|
138
|
+
cronExpression: snapshot.cron_expression,
|
|
139
|
+
prompt: snapshot.prompt,
|
|
140
|
+
recurring: snapshot.recurring,
|
|
141
|
+
nextFireAt: snapshot.nextFireAt,
|
|
142
|
+
createdAt: snapshot.createdAt,
|
|
143
|
+
}))
|
|
144
|
+
.sort((a, b) => b.createdAt - a.createdAt);
|
|
145
|
+
const worstState: JobsWorstState = this.#failedUnacknowledged
|
|
146
|
+
? "failed"
|
|
147
|
+
: activeMonitors.length > 0 || crons.length > 0
|
|
148
|
+
? "running"
|
|
149
|
+
: "none";
|
|
150
|
+
this.#snapshot = {
|
|
151
|
+
monitors,
|
|
152
|
+
crons,
|
|
153
|
+
activeMonitorCount: activeMonitors.length,
|
|
154
|
+
activeCronCount: crons.length,
|
|
155
|
+
worstState,
|
|
156
|
+
failedUnacknowledged: this.#failedUnacknowledged,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Return the precomputed snapshot (recomputed on each upstream change). */
|
|
161
|
+
getSnapshot(): JobsSnapshot {
|
|
162
|
+
return this.#snapshot;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Clear the failure latch (called when the user opens the jobs overlay). */
|
|
166
|
+
acknowledgeFailures(): void {
|
|
167
|
+
for (const job of this.#listMonitorJobs()) {
|
|
168
|
+
if (job.status === "failed") this.#acknowledgedFailedIds.add(job.id);
|
|
169
|
+
}
|
|
170
|
+
if (!this.#failedUnacknowledged) return;
|
|
171
|
+
this.#failedUnacknowledged = false;
|
|
172
|
+
this.#recompute();
|
|
173
|
+
this.#emit();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Cancel a running monitor job. Returns true when the job was cancelled. */
|
|
177
|
+
cancelMonitor(id: string): boolean {
|
|
178
|
+
return this.#manager.cancel(id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Delete a visible scheduled cron job. Returns true when removed. */
|
|
182
|
+
deleteCron(id: string): boolean {
|
|
183
|
+
return deleteCronJobById(this.#ownerId, id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Bounded tail of a monitor job's captured output (for the detail view). */
|
|
187
|
+
getMonitorOutput(id: string): string {
|
|
188
|
+
const slice = this.#manager.readOutputSince(id, 0, this.#ownerId ? { ownerId: this.#ownerId } : undefined);
|
|
189
|
+
return slice?.text ?? "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
dispose(): void {
|
|
193
|
+
this.#disposed = true;
|
|
194
|
+
for (const unsubscribe of this.#unsubscribers) {
|
|
195
|
+
try {
|
|
196
|
+
unsubscribe();
|
|
197
|
+
} catch {
|
|
198
|
+
// best-effort teardown
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
this.#unsubscribers.length = 0;
|
|
202
|
+
this.#listeners.clear();
|
|
203
|
+
}
|
|
204
|
+
}
|