janissary 0.5.0
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/LICENSE +75 -0
- package/README.md +28 -0
- package/agent-names.json +52 -0
- package/bin/janus.mjs +27 -0
- package/chrome-extension/_metadata/generated_indexed_rulesets/_ruleset1 +0 -0
- package/chrome-extension/content-script.js +62 -0
- package/chrome-extension/manifest.json +25 -0
- package/chrome-extension/rules.json +16 -0
- package/dist/acp/index.js +93 -0
- package/dist/acp/loop.js +75 -0
- package/dist/acp/manager.js +126 -0
- package/dist/acp/rate-limit.js +8 -0
- package/dist/acp/runner.js +18 -0
- package/dist/acp/tools.js +27 -0
- package/dist/acp-loop.js +75 -0
- package/dist/acp-manager.js +109 -0
- package/dist/acp-runner.js +18 -0
- package/dist/acp.js +91 -0
- package/dist/agent/commands.js +29 -0
- package/dist/agent/communication-manager.js +71 -0
- package/dist/agent/state.js +58 -0
- package/dist/agent-commands.js +29 -0
- package/dist/agent-communication-manager.js +71 -0
- package/dist/agent-state.js +58 -0
- package/dist/app-themes.js +13 -0
- package/dist/browser/command.js +104 -0
- package/dist/browser/index.js +124 -0
- package/dist/browser/tab-helpers.js +51 -0
- package/dist/browser/tab.js +99 -0
- package/dist/browser-command.js +104 -0
- package/dist/browser-tab-helpers.js +51 -0
- package/dist/browser-tab.js +99 -0
- package/dist/browser.js +124 -0
- package/dist/buffer.js +42 -0
- package/dist/bus.js +52 -0
- package/dist/capture/manager.js +48 -0
- package/dist/capture/router.js +18 -0
- package/dist/capture-manager.js +48 -0
- package/dist/capture-router.js +20 -0
- package/dist/chrome-extension-loader.js +62 -0
- package/dist/cli-args.js +80 -0
- package/dist/command-manager.js +126 -0
- package/dist/command-router.js +14 -0
- package/dist/commands/acp-reset.js +13 -0
- package/dist/commands/acp.js +5 -0
- package/dist/commands/agent.js +5 -0
- package/dist/commands/broadcast.js +22 -0
- package/dist/commands/browser.js +5 -0
- package/dist/commands/clear.js +5 -0
- package/dist/commands/close.js +42 -0
- package/dist/commands/connection.js +5 -0
- package/dist/commands/db.js +5 -0
- package/dist/commands/edit.js +18 -0
- package/dist/commands/files.js +13 -0
- package/dist/commands/hist.js +7 -0
- package/dist/commands/index.js +62 -0
- package/dist/commands/monitor.js +61 -0
- package/dist/commands/msg.js +17 -0
- package/dist/commands/next.js +7 -0
- package/dist/commands/notifications.js +16 -0
- package/dist/commands/notify.js +20 -0
- package/dist/commands/open.js +41 -0
- package/dist/commands/profile.js +5 -0
- package/dist/commands/queue.js +37 -0
- package/dist/commands/quit.js +6 -0
- package/dist/commands/rename.js +12 -0
- package/dist/commands/resolve-target.js +12 -0
- package/dist/commands/schedule.js +74 -0
- package/dist/commands/search.js +26 -0
- package/dist/commands/send.js +51 -0
- package/dist/commands/state.js +9 -0
- package/dist/commands/syntax.js +36 -0
- package/dist/commands/tasks.js +7 -0
- package/dist/commands/theme.js +41 -0
- package/dist/commands/types.js +1 -0
- package/dist/commands.js +70 -0
- package/dist/completion/browser.js +24 -0
- package/dist/completion/fs.js +26 -0
- package/dist/completion/handlers.js +81 -0
- package/dist/completion/helpers.js +43 -0
- package/dist/completion/index.js +43 -0
- package/dist/completion-browser.js +24 -0
- package/dist/completion-fs.js +26 -0
- package/dist/completion-handlers.js +69 -0
- package/dist/completion-helpers.js +43 -0
- package/dist/completion.js +42 -0
- package/dist/config.js +66 -0
- package/dist/connection/close.js +41 -0
- package/dist/connection/manager.js +93 -0
- package/dist/connection/parsing.js +28 -0
- package/dist/connection-close.js +41 -0
- package/dist/connection-manager.js +93 -0
- package/dist/connection-parsing.js +28 -0
- package/dist/connections.js +75 -0
- package/dist/controller-events.js +34 -0
- package/dist/controller-file-tree.js +36 -0
- package/dist/controller.js +201 -0
- package/dist/database/index.js +83 -0
- package/dist/database/manager.js +67 -0
- package/dist/database/parsing.js +54 -0
- package/dist/database/query.js +41 -0
- package/dist/database-manager.js +67 -0
- package/dist/database-parsing.js +54 -0
- package/dist/database-query.js +41 -0
- package/dist/database.js +83 -0
- package/dist/datetime.js +15 -0
- package/dist/editor/save.js +27 -0
- package/dist/editor/sync.js +10 -0
- package/dist/editor/watch-manager.js +84 -0
- package/dist/editor-save.js +24 -0
- package/dist/editor-watch-manager.js +84 -0
- package/dist/file-tree-args.js +36 -0
- package/dist/file-tree-git-refresh.js +33 -0
- package/dist/file-tree-manager.js +252 -0
- package/dist/file-tree-moves.js +26 -0
- package/dist/file-tree-open.js +56 -0
- package/dist/file-tree-rebuild.js +22 -0
- package/dist/file-tree-watch.js +27 -0
- package/dist/file-tree.js +82 -0
- package/dist/git-status.js +64 -0
- package/dist/github-token.js +19 -0
- package/dist/global-history.js +48 -0
- package/dist/harness/auto-approve.js +79 -0
- package/dist/harness/busy-status.js +91 -0
- package/dist/harness/capture-file.js +28 -0
- package/dist/harness/index.js +87 -0
- package/dist/harness/manager.js +182 -0
- package/dist/harness/models.js +8 -0
- package/dist/harness/recorder.js +89 -0
- package/dist/harness/recording-file.js +25 -0
- package/dist/harness/screen.js +79 -0
- package/dist/harness-capture-file.js +28 -0
- package/dist/harness-manager.js +112 -0
- package/dist/harness-models.js +8 -0
- package/dist/harness-screen.js +72 -0
- package/dist/harness.js +54 -0
- package/dist/index.js +163 -0
- package/dist/instance-lock.js +34 -0
- package/dist/interactive.js +33 -0
- package/dist/main.js +203 -0
- package/dist/managers.js +1 -0
- package/dist/message-handler.js +134 -0
- package/dist/messaging.js +47 -0
- package/dist/monitor/acp.js +24 -0
- package/dist/monitor/ask.js +31 -0
- package/dist/monitor/context.js +27 -0
- package/dist/monitor/editor-feed.js +49 -0
- package/dist/monitor/feed-diff.js +22 -0
- package/dist/monitor/feeds.js +19 -0
- package/dist/monitor/framing.js +29 -0
- package/dist/monitor/harness-feed.js +21 -0
- package/dist/monitor/info.js +29 -0
- package/dist/monitor/manager.js +221 -0
- package/dist/monitor/page-feed.js +21 -0
- package/dist/monitor/parsing.js +82 -0
- package/dist/monitor/reply.js +11 -0
- package/dist/monitor/session.js +36 -0
- package/dist/monitor/stop.js +33 -0
- package/dist/monitor/suggestion.js +12 -0
- package/dist/monitor/targets.js +69 -0
- package/dist/monitor/window.js +109 -0
- package/dist/monitor-acp.js +22 -0
- package/dist/monitor-info.js +18 -0
- package/dist/monitor-manager.js +233 -0
- package/dist/monitor-parsing.js +68 -0
- package/dist/monitor-session.js +19 -0
- package/dist/monitor-targets.js +37 -0
- package/dist/monitor-window.js +81 -0
- package/dist/notifications-tab.js +32 -0
- package/dist/notifications.js +87 -0
- package/dist/open-file-manager.js +100 -0
- package/dist/openers/editor.js +41 -0
- package/dist/openers/external-viewer.js +11 -0
- package/dist/openers/image.js +27 -0
- package/dist/openers/index.js +16 -0
- package/dist/openers/markdown.js +20 -0
- package/dist/openers/os-open.js +24 -0
- package/dist/openers/page.js +51 -0
- package/dist/openers/size.js +13 -0
- package/dist/openers/types.js +1 -0
- package/dist/page/sync.js +10 -0
- package/dist/paths.js +45 -0
- package/dist/persona-parsing.js +49 -0
- package/dist/personas.js +44 -0
- package/dist/profile/agent-opener.js +154 -0
- package/dist/profile/files.js +17 -0
- package/dist/profile/manager.js +72 -0
- package/dist/profile/monitors.js +23 -0
- package/dist/profile/notifications.js +11 -0
- package/dist/profile-agent-opener.js +136 -0
- package/dist/profile-manager.js +72 -0
- package/dist/profiles.js +143 -0
- package/dist/protocol.js +1 -0
- package/dist/pseudoterminal-manager.js +106 -0
- package/dist/pty.js +41 -0
- package/dist/recognizers/acp.js +38 -0
- package/dist/recognizers/analyze.js +49 -0
- package/dist/recognizers/bash.js +51 -0
- package/dist/recognizers/db.js +26 -0
- package/dist/recognizers/index.js +1 -0
- package/dist/recognizers/lexicon.js +11 -0
- package/dist/recognizers/types.js +5 -0
- package/dist/resolve.js +39 -0
- package/dist/route-choice.js +11 -0
- package/dist/sandbox/index.js +217 -0
- package/dist/sandbox/paths.js +178 -0
- package/dist/sandbox/profile.js +161 -0
- package/dist/sandbox-paths.js +159 -0
- package/dist/sandbox-profile.js +161 -0
- package/dist/sandbox.js +217 -0
- package/dist/schedule/display.js +19 -0
- package/dist/schedule/helpers.js +76 -0
- package/dist/schedule/index.js +76 -0
- package/dist/schedule/manager.js +101 -0
- package/dist/schedule/parsing.js +63 -0
- package/dist/schedule/time.js +34 -0
- package/dist/schedule-display.js +19 -0
- package/dist/schedule-helpers.js +76 -0
- package/dist/schedule-manager.js +98 -0
- package/dist/schedule-parsing.js +63 -0
- package/dist/schedule-time.js +34 -0
- package/dist/schedule.js +76 -0
- package/dist/search-matches.js +40 -0
- package/dist/security.js +44 -0
- package/dist/shell-manager.js +139 -0
- package/dist/shell.js +59 -0
- package/dist/ssh-manager.js +42 -0
- package/dist/ssh.js +39 -0
- package/dist/startup-errors.js +21 -0
- package/dist/state-event.js +14 -0
- package/dist/state-format.js +38 -0
- package/dist/syntax-themes.js +14 -0
- package/dist/tab/agent-state.js +19 -0
- package/dist/tab/cleanup.js +33 -0
- package/dist/tab/colors.js +43 -0
- package/dist/tab/creators.js +95 -0
- package/dist/tab/dock.js +35 -0
- package/dist/tab/focus-history.js +41 -0
- package/dist/tab/formatting-handlers.js +77 -0
- package/dist/tab/formatting.js +21 -0
- package/dist/tab/history.js +14 -0
- package/dist/tab/index.js +65 -0
- package/dist/tab/manager.js +329 -0
- package/dist/tab/openers.js +35 -0
- package/dist/tab/queue.js +33 -0
- package/dist/tab/rehydrate.js +15 -0
- package/dist/tab/reorder.js +15 -0
- package/dist/tab/transcript.js +15 -0
- package/dist/tab/utils.js +55 -0
- package/dist/tab/view.js +42 -0
- package/dist/tab-cleanup.js +27 -0
- package/dist/tab-colors.js +43 -0
- package/dist/tab-creators.js +85 -0
- package/dist/tab-formatting-handlers.js +77 -0
- package/dist/tab-formatting.js +21 -0
- package/dist/tab-manager.js +418 -0
- package/dist/tab-utils.js +55 -0
- package/dist/tab.js +57 -0
- package/dist/tasks.js +32 -0
- package/dist/transcript/logger.js +36 -0
- package/dist/transcript/store.js +66 -0
- package/dist/types.js +6 -0
- package/dist/user-agent.js +65 -0
- package/dist/word-wrapping.js +42 -0
- package/dist/workspace-manager.js +40 -0
- package/dist/workspace.js +120 -0
- package/help.md +97 -0
- package/package.json +138 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
// `git status` reports paths relative to the repository root, even when run from a subdirectory.
|
|
5
|
+
// When the tree root sits below the repo root, strip that subdirectory prefix so paths come back
|
|
6
|
+
// relative to the tree root (matching the tree's own row paths). `prefix` is the tree root's path
|
|
7
|
+
// within the repo (e.g. `sub/`, always forward-slashed) and is empty when the tree root is the
|
|
8
|
+
// repo root. The `-- .` pathspec already limits results to the subtree, so every path is prefixed.
|
|
9
|
+
function stripPrefix(p, prefix) {
|
|
10
|
+
return prefix && p.startsWith(prefix) ? p.slice(prefix.length) : p;
|
|
11
|
+
}
|
|
12
|
+
// Split a NUL-terminated `git status --porcelain=v1 -z` payload into the set of changed
|
|
13
|
+
// root-relative paths. Each entry is `XY path` (two status columns, a space, then the path). A
|
|
14
|
+
// rename or copy entry (`R`/`C` in either column) is followed by one extra NUL-terminated field —
|
|
15
|
+
// the original path — which counts as one more changed path; every other entry is a single field.
|
|
16
|
+
function parsePorcelain(stdout, prefix) {
|
|
17
|
+
const changed = new Set();
|
|
18
|
+
const fields = stdout.split('\0');
|
|
19
|
+
for (let i = 0; i < fields.length; i++) {
|
|
20
|
+
const entry = fields[i];
|
|
21
|
+
if (!entry)
|
|
22
|
+
continue;
|
|
23
|
+
const status = entry.slice(0, 2);
|
|
24
|
+
changed.add(stripPrefix(entry.slice(3), prefix));
|
|
25
|
+
if (status.includes('R') || status.includes('C')) {
|
|
26
|
+
const original = fields[++i];
|
|
27
|
+
if (original)
|
|
28
|
+
changed.add(stripPrefix(original, prefix));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return changed;
|
|
32
|
+
}
|
|
33
|
+
// Given an absolute directory root, resolve to the set of root-relative paths git considers
|
|
34
|
+
// changed — modified, staged, or untracked (all three treated identically). Scoped to `root`'s own
|
|
35
|
+
// subtree via the `-- .` pathspec so opening a tree deep inside a large repo does not pay a
|
|
36
|
+
// full-repository status cost. Resolves to an empty set — never rejects — when `root` is not inside
|
|
37
|
+
// a git repository or the command fails for any reason (git missing, non-zero exit), so a non-git
|
|
38
|
+
// directory renders with no coloring and no error.
|
|
39
|
+
export async function changedPaths(root) {
|
|
40
|
+
try {
|
|
41
|
+
const [status, prefix] = await Promise.all([
|
|
42
|
+
execFileAsync('git', ['status', '--porcelain=v1', '--untracked-files=all', '-z', '--', '.'], { cwd: root }),
|
|
43
|
+
execFileAsync('git', ['rev-parse', '--show-prefix'], { cwd: root }),
|
|
44
|
+
]);
|
|
45
|
+
return parsePorcelain(status.stdout, prefix.stdout.trim());
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return new Set();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Given an absolute directory root, resolve to the name of the currently checked-out branch — the
|
|
52
|
+
// literal string `HEAD` for a detached checkout, matching git's own convention. Resolves to
|
|
53
|
+
// `undefined` — never rejects — when `root` is not inside a git repository or the command fails
|
|
54
|
+
// for any reason, so a non-git directory renders with no branch text and no error.
|
|
55
|
+
export async function currentBranch(root) {
|
|
56
|
+
try {
|
|
57
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root });
|
|
58
|
+
const branch = stdout.trim();
|
|
59
|
+
return branch || undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
let githubToken;
|
|
4
|
+
// Reads `.janissary/github-token` — a user-provisioned, narrowly-scoped GitHub fine-grained PAT
|
|
5
|
+
// (Contents + Pull requests write, Metadata read) used to authenticate `git push`/`gh` from inside
|
|
6
|
+
// a sandboxed workspace. Absent by default: no token, no injection, workspaces behave as before.
|
|
7
|
+
export function loadGithubToken(projectDir) {
|
|
8
|
+
const tokenPath = path.join(projectDir, '.janissary', 'github-token');
|
|
9
|
+
if (!existsSync(tokenPath)) {
|
|
10
|
+
githubToken = undefined;
|
|
11
|
+
return githubToken;
|
|
12
|
+
}
|
|
13
|
+
const token = readFileSync(tokenPath, 'utf8').trim();
|
|
14
|
+
githubToken = token || undefined;
|
|
15
|
+
return githubToken;
|
|
16
|
+
}
|
|
17
|
+
export function getGithubToken() {
|
|
18
|
+
return githubToken;
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const MAX_ENTRIES = 1000;
|
|
5
|
+
let historyPath = '';
|
|
6
|
+
let entries = [];
|
|
7
|
+
function isHistoryEntry(x) {
|
|
8
|
+
return (typeof x === 'object' && x !== null &&
|
|
9
|
+
typeof x.command === 'string' &&
|
|
10
|
+
typeof x.tab === 'string' &&
|
|
11
|
+
typeof x.timestamp === 'number');
|
|
12
|
+
}
|
|
13
|
+
export function initGlobalHistory(home) {
|
|
14
|
+
const base = home ?? homedir();
|
|
15
|
+
const dir = path.join(base, '.janissary');
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
historyPath = path.join(dir, 'history.json');
|
|
18
|
+
if (!existsSync(historyPath)) {
|
|
19
|
+
entries = [];
|
|
20
|
+
writeFileSync(historyPath, '[]');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(historyPath, 'utf8'));
|
|
25
|
+
entries = Array.isArray(parsed) ? parsed.filter(isHistoryEntry) : [];
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
entries = [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function recordGlobalHistory(command, tab) {
|
|
32
|
+
if (!historyPath)
|
|
33
|
+
return;
|
|
34
|
+
if (entries.at(-1)?.command === command)
|
|
35
|
+
return;
|
|
36
|
+
entries = [...entries, { command, tab, timestamp: Date.now() }].slice(-MAX_ENTRIES);
|
|
37
|
+
try {
|
|
38
|
+
writeFileSync(historyPath, JSON.stringify(entries, null, 2));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// persistence failure is non-fatal; in-memory buffer still works
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function globalCommands() {
|
|
45
|
+
if (!historyPath)
|
|
46
|
+
return [];
|
|
47
|
+
return entries.map((e) => e.command);
|
|
48
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// How far up from the bottom of the capture the gate menu is allowed to sit. The real permission
|
|
2
|
+
// menu always occupies the final few lines; requiring both anchors inside this window rejects the
|
|
3
|
+
// classic false positive of a session merely *printing* gate-shaped text that has scrolled upward.
|
|
4
|
+
const GATE_WINDOW_LINES = 10;
|
|
5
|
+
// The highlighted default option-1 line, e.g. `❯ 1. Yes` (possibly with trailing text). The `❯`
|
|
6
|
+
// glyph is the highlight marker and is required — a gate always defaults to "Yes".
|
|
7
|
+
function isYesDefaultLine(line) {
|
|
8
|
+
const trimmed = line.trim();
|
|
9
|
+
if (!trimmed.startsWith('❯'))
|
|
10
|
+
return false;
|
|
11
|
+
const afterMarker = trimmed.slice('❯'.length).trimStart();
|
|
12
|
+
if (!afterMarker.startsWith('1.'))
|
|
13
|
+
return false;
|
|
14
|
+
return afterMarker.slice('1.'.length).trimStart().startsWith('Yes');
|
|
15
|
+
}
|
|
16
|
+
// A `No` option line, numbered `2.` (two-option gates) or `3.` (three-option gates), tolerating a
|
|
17
|
+
// missing space after the number (the MCP capture rendered `2.Yes` with no space).
|
|
18
|
+
function isNoOptionLine(line) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!(trimmed.startsWith('2.') || trimmed.startsWith('3.')))
|
|
21
|
+
return false;
|
|
22
|
+
return trimmed.slice('2.'.length).trimStart().startsWith('No');
|
|
23
|
+
}
|
|
24
|
+
// claude's permission gate, matched on menu *structure* rather than the (variable) question,
|
|
25
|
+
// footer, or option wording: within the last few lines, a highlighted `❯ 1. Yes` line followed by
|
|
26
|
+
// a later `2. No`/`3. No` line. Deterministic, so false positives stay near-zero.
|
|
27
|
+
function detectClaudeGate(text) {
|
|
28
|
+
const window = text.split('\n').slice(-GATE_WINDOW_LINES);
|
|
29
|
+
const yesIndex = window.findIndex((line) => isYesDefaultLine(line));
|
|
30
|
+
if (yesIndex === -1)
|
|
31
|
+
return false;
|
|
32
|
+
return window.slice(yesIndex + 1).some((line) => isNoOptionLine(line));
|
|
33
|
+
}
|
|
34
|
+
// Per-harness gate detectors + approval keystrokes. Only claude is populated today; opencode/codex
|
|
35
|
+
// are deliberate later work (each needs its own captured gate signatures). claude's keystroke is
|
|
36
|
+
// Enter (`\r`), which accepts the highlighted default "Yes" across every captured variant — `y`
|
|
37
|
+
// does not work because these are numbered menus, not y/n prompts.
|
|
38
|
+
const GATE_TABLE = {
|
|
39
|
+
claude: { detect: detectClaudeGate, keystroke: '\r' },
|
|
40
|
+
};
|
|
41
|
+
// Whether the rendered screen `text` is a recognized permission gate for `harnessName`. Pure and
|
|
42
|
+
// deterministic; any harness without a table entry returns false.
|
|
43
|
+
export function detectPermissionGate(text, harnessName) {
|
|
44
|
+
const entry = GATE_TABLE[harnessName];
|
|
45
|
+
return entry ? entry.detect(text) : false;
|
|
46
|
+
}
|
|
47
|
+
// Watches a harness's screen captures and injects the approval keystroke when a permission gate is
|
|
48
|
+
// detected, then reports it. A loop guard prevents re-injecting into an identical, uncleared gate.
|
|
49
|
+
export class HarnessAutoApprover {
|
|
50
|
+
opts;
|
|
51
|
+
lastApprovedText;
|
|
52
|
+
stuckNotified = false;
|
|
53
|
+
keystroke;
|
|
54
|
+
constructor(opts) {
|
|
55
|
+
this.opts = opts;
|
|
56
|
+
this.keystroke = GATE_TABLE[opts.harnessName]?.keystroke ?? '\r';
|
|
57
|
+
}
|
|
58
|
+
// Whether the approver has stood down on a gate it could not clear — the signal busy/ready
|
|
59
|
+
// tracking uses to badge a gate that still needs the user despite auto-approve being on.
|
|
60
|
+
get isStuck() { return this.stuckNotified; }
|
|
61
|
+
onCapture(capture) {
|
|
62
|
+
if (!detectPermissionGate(capture.text, this.opts.harnessName)) {
|
|
63
|
+
this.lastApprovedText = undefined;
|
|
64
|
+
this.stuckNotified = false;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (capture.text === this.lastApprovedText) {
|
|
68
|
+
if (!this.stuckNotified) {
|
|
69
|
+
this.stuckNotified = true;
|
|
70
|
+
this.opts.notify('Auto-approve could not clear the permission prompt; standing down');
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.opts.approve(this.keystroke);
|
|
75
|
+
this.lastApprovedText = capture.text;
|
|
76
|
+
this.stuckNotified = false;
|
|
77
|
+
this.opts.notify('Auto-approved a permission prompt');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { detectPermissionGate } from './auto-approve.js';
|
|
2
|
+
// claude and codex both animate a Braille spinner glyph (U+2800–U+28FF) at the start of the OSC
|
|
3
|
+
// title while generating; an idle title leads with something else (claude's `✳`, codex's cwd
|
|
4
|
+
// basename). The leading glyph alone is the discriminator.
|
|
5
|
+
function leadsWithBraille(title) {
|
|
6
|
+
const code = title.codePointAt(0);
|
|
7
|
+
return code !== undefined && code >= 0x28_00 && code <= 0x28_ff;
|
|
8
|
+
}
|
|
9
|
+
// Shared claude/codex title rule: leading Braille spinner glyph means busy, any other non-empty
|
|
10
|
+
// title means ready. Returns undefined when there is no usable title to classify.
|
|
11
|
+
function classifyTitle(title) {
|
|
12
|
+
const trimmed = title?.trim();
|
|
13
|
+
if (!trimmed)
|
|
14
|
+
return undefined;
|
|
15
|
+
return leadsWithBraille(trimmed) ? 'busy' : 'ready';
|
|
16
|
+
}
|
|
17
|
+
// claude's rendered-screen fallback for when no title is present: an `esc to interrupt` footer
|
|
18
|
+
// means it is still generating; otherwise a live input prompt box (a `❯` caret line that is not
|
|
19
|
+
// the permission gate's `❯ 1. Yes` option) means it is sitting at its own prompt.
|
|
20
|
+
function classifyClaudeScreen(text) {
|
|
21
|
+
if (text.includes('esc to interrupt'))
|
|
22
|
+
return 'busy';
|
|
23
|
+
const promptBox = text.split('\n').some((line) => {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
return trimmed.startsWith('❯') && !trimmed.slice('❯'.length).trimStart().startsWith('1.');
|
|
26
|
+
});
|
|
27
|
+
return promptBox ? 'ready' : 'busy';
|
|
28
|
+
}
|
|
29
|
+
// opencode has no OSC-title state signal (its title is a static app/session name), so busy is
|
|
30
|
+
// recognized from the rendered frame: a progress-bar run of block/dot glyphs, or the interrupt
|
|
31
|
+
// hint footer (rendered `esc interrupt` in 1.17.18, `esc to interrupt` in earlier versions).
|
|
32
|
+
function classifyOpencodeScreen(text) {
|
|
33
|
+
if (/[■⬝]{4}/.test(text))
|
|
34
|
+
return 'busy';
|
|
35
|
+
if (text.includes('esc interrupt') || text.includes('esc to interrupt'))
|
|
36
|
+
return 'busy';
|
|
37
|
+
return 'ready';
|
|
38
|
+
}
|
|
39
|
+
// Per-harness busy/ready detectors, keyed by harness name (same shape as auto-approve's
|
|
40
|
+
// GATE_TABLE). A harness without an entry keeps today's coarse behavior — busy for the whole
|
|
41
|
+
// process lifetime. Signals were confirmed against claude 2.1.210, codex-cli 0.144.4, and
|
|
42
|
+
// opencode 1.17.18.
|
|
43
|
+
const BUSY_TABLE = {
|
|
44
|
+
claude: {
|
|
45
|
+
classify: (capture) => classifyTitle(capture.title) ?? classifyClaudeScreen(capture.text),
|
|
46
|
+
},
|
|
47
|
+
codex: {
|
|
48
|
+
// codex emitted a title in every observed state; before the first title arrives, stay busy
|
|
49
|
+
// (matching the busy-at-spawn initial state) rather than guessing from the screen.
|
|
50
|
+
classify: (capture) => classifyTitle(capture.title) ?? 'busy',
|
|
51
|
+
},
|
|
52
|
+
opencode: {
|
|
53
|
+
classify: (capture) => classifyOpencodeScreen(capture.text),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
// Classify a capture for `harnessName`, or undefined when the harness has no detector.
|
|
57
|
+
export function classifyBusy(capture, harnessName) {
|
|
58
|
+
return BUSY_TABLE[harnessName]?.classify(capture);
|
|
59
|
+
}
|
|
60
|
+
// Build the per-tab capture handler that keeps the tab's busy dot in sync with the harness's
|
|
61
|
+
// actual state, or undefined when the harness has no detector (leaving the coarse spawn-to-exit
|
|
62
|
+
// busy behavior untouched). A visible permission gate outranks the busy/ready signals: the
|
|
63
|
+
// harness is idle, blocked on the user, so busy clears immediately — and the tab is badged unread
|
|
64
|
+
// when nothing is going to answer the gate (`approver` missing, or stood down on it). A busy→ready
|
|
65
|
+
// transition is debounced to two consecutive ready captures so a brief mid-generation pause does
|
|
66
|
+
// not flicker the dot off; ready→busy is applied immediately.
|
|
67
|
+
export function busyStatusHandler(name, label, managers, approver) {
|
|
68
|
+
const entry = BUSY_TABLE[name];
|
|
69
|
+
if (!entry)
|
|
70
|
+
return undefined;
|
|
71
|
+
let pendingReady = false;
|
|
72
|
+
return (capture) => {
|
|
73
|
+
if (detectPermissionGate(capture.text, name)) {
|
|
74
|
+
pendingReady = false;
|
|
75
|
+
managers.tab.deleteBusy(label);
|
|
76
|
+
if (!approver || approver.isStuck)
|
|
77
|
+
managers.tab.markUnread(label);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const state = classifyBusy(capture, name);
|
|
81
|
+
if (state === 'busy') {
|
|
82
|
+
pendingReady = false;
|
|
83
|
+
managers.tab.addBusy(label);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (pendingReady)
|
|
87
|
+
managers.tab.deleteBusy(label);
|
|
88
|
+
else
|
|
89
|
+
pendingReady = true;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
let captureDirectory = '';
|
|
4
|
+
export function initHarnessCaptureDirectory(projectDirectory) {
|
|
5
|
+
captureDirectory = path.join(projectDirectory, '.janissary', 'captures');
|
|
6
|
+
}
|
|
7
|
+
export function ensureCaptureDirectory() {
|
|
8
|
+
mkdirSync(captureDirectory, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
// Write a screen capture for the tab labeled `label` to `<label>-<capturedAt-iso>.txt` and return
|
|
11
|
+
// the absolute path. The label is sanitized rather than rejected — the tab exists, so its label is
|
|
12
|
+
// legitimate even when it holds filename-hostile characters (`/`, `.`).
|
|
13
|
+
export function writeCaptureFile(label, capturedAt, text) {
|
|
14
|
+
const safeLabel = label.replaceAll(/[^\w-]/g, '-');
|
|
15
|
+
const timestamp = new Date(capturedAt).toISOString().replaceAll(/[:.]/g, '-');
|
|
16
|
+
const file = path.join(captureDirectory, `${safeLabel}-${timestamp}.txt`);
|
|
17
|
+
ensureCaptureDirectory();
|
|
18
|
+
writeFileSync(file, text);
|
|
19
|
+
return file;
|
|
20
|
+
}
|
|
21
|
+
export function clearCaptureDirectory() {
|
|
22
|
+
if (!captureDirectory)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
rmSync(captureDirectory, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
catch { /* ignore */ }
|
|
28
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The embeddable AI coding harnesses, mapped to the binary that launches each. In the web app a
|
|
2
|
+
// harness runs in a PTY rendered as an inline terminal card (see src/server/controller.ts).
|
|
3
|
+
export const HARNESS_COMMANDS = {
|
|
4
|
+
claude: 'claude',
|
|
5
|
+
opencode: 'opencode',
|
|
6
|
+
codex: 'codex',
|
|
7
|
+
};
|
|
8
|
+
export const HARNESS_NAMES = Object.keys(HARNESS_COMMANDS);
|
|
9
|
+
// Find a `--flag <value>` pair anywhere in `tokens`. Returns the value, `undefined` if the flag
|
|
10
|
+
// isn't present, or an error string if the flag is present with no following value.
|
|
11
|
+
function findFlagValue(tokens, flag) {
|
|
12
|
+
const index = tokens.findIndex((t) => t.toLowerCase() === flag);
|
|
13
|
+
if (index === -1)
|
|
14
|
+
return undefined;
|
|
15
|
+
const value = tokens[index + 1];
|
|
16
|
+
if (!value)
|
|
17
|
+
return { error: `Usage: harness <${HARNESS_NAMES.join('|')}> ${flag} <value>.` };
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse a `harness <name> [as <label>] [-w|--workspace] [--offline] [-y|--yes] [--model <name>]
|
|
22
|
+
* [--effort <level>]` command, validating the harness name against the known set. `as <label>`
|
|
23
|
+
* gives the new tab a custom label instead of the harness name (still de-duplicated against
|
|
24
|
+
* existing tab labels). `--offline` adds a network-deny rule to the tab's sandbox profile (only
|
|
25
|
+
* meaningful alongside `-w`/`--workspace`). `-y`/`--yes` auto-approves the harness's own permission
|
|
26
|
+
* prompts; it is claude-only and requires `-w`/`--workspace` (both are hard errors otherwise).
|
|
27
|
+
* `--model <name>` selects a model, validated by the caller against the harness's catalog.
|
|
28
|
+
* `--effort <level>` selects an effort level, passed through verbatim with no validation.
|
|
29
|
+
* `harness capture <name>` is the other form: `<name>` targets an existing harness tab by label
|
|
30
|
+
* (`capture` can never collide with a harness name — it is not a HARNESS_COMMANDS key).
|
|
31
|
+
*/
|
|
32
|
+
export function parseHarnessCommand(input) {
|
|
33
|
+
const rest = input.replace(/^harness\b\s*/i, '').trim();
|
|
34
|
+
if (!rest)
|
|
35
|
+
return { error: `Usage: harness <${HARNESS_NAMES.join('|')}> [as <label>] [-w] [-y].` };
|
|
36
|
+
const tokens = rest.split(/\s+/);
|
|
37
|
+
if (tokens[0].toLowerCase() === 'capture') {
|
|
38
|
+
const label = tokens[1];
|
|
39
|
+
if (!label)
|
|
40
|
+
return { error: 'Usage: harness capture <name>.' };
|
|
41
|
+
return { capture: true, label };
|
|
42
|
+
}
|
|
43
|
+
const name = tokens[0].toLowerCase();
|
|
44
|
+
if (HARNESS_COMMANDS[name] === undefined) {
|
|
45
|
+
return { error: `Unknown harness "${name}". Choose from: ${HARNESS_NAMES.join(', ')}.` };
|
|
46
|
+
}
|
|
47
|
+
const rest_ = tokens.slice(1);
|
|
48
|
+
const workspace = rest_.some((t) => t === '-w' || t === '--workspace');
|
|
49
|
+
const offline = rest_.some((t) => t.toLowerCase() === '--offline');
|
|
50
|
+
const autoApprove = rest_.some((t) => t === '-y' || t === '--yes');
|
|
51
|
+
// Claude-only comes first: adding -w would not make `harness opencode -y` valid, so pointing at
|
|
52
|
+
// -w would misdirect — the harness choice is the real blocker.
|
|
53
|
+
if (autoApprove && name !== 'claude')
|
|
54
|
+
return { error: '-y/--yes is only supported for the claude harness.' };
|
|
55
|
+
if (autoApprove && !workspace)
|
|
56
|
+
return { error: '-y/--yes requires -w/--workspace: auto-approval is only allowed in a sandboxed workspace.' };
|
|
57
|
+
const model = findFlagValue(rest_, '--model');
|
|
58
|
+
if (model !== undefined && typeof model !== 'string')
|
|
59
|
+
return model;
|
|
60
|
+
const effort = findFlagValue(rest_, '--effort');
|
|
61
|
+
if (effort !== undefined && typeof effort !== 'string')
|
|
62
|
+
return effort;
|
|
63
|
+
const asIndex = rest_.findIndex((t) => t.toLowerCase() === 'as');
|
|
64
|
+
if (asIndex === -1)
|
|
65
|
+
return { name, workspace, offline, autoApprove, model, effort };
|
|
66
|
+
const label = rest_[asIndex + 1];
|
|
67
|
+
if (!label)
|
|
68
|
+
return { error: `Usage: harness <${HARNESS_NAMES.join('|')}> as <label>.` };
|
|
69
|
+
return { name, workspace, offline, autoApprove, model, effort, label };
|
|
70
|
+
}
|
|
71
|
+
// Single-quote a value for embedding in a `shell -lc '<command>'` string, escaping any embedded
|
|
72
|
+
// single quotes (`'` → `'\''`).
|
|
73
|
+
function shellQuote(value) {
|
|
74
|
+
return `'${value.replaceAll("'", String.raw `'\''`)}'`;
|
|
75
|
+
}
|
|
76
|
+
// Build the shell command string that launches a harness binary, optionally with a model and/or
|
|
77
|
+
// effort flag. `buildHarnessCommand('opencode', 'opencode-go/deepseek-v4-pro', 'high')` →
|
|
78
|
+
// `opencode --model 'opencode-go/deepseek-v4-pro' --effort 'high'`.
|
|
79
|
+
export function buildHarnessCommand(name, model, effort) {
|
|
80
|
+
const program = HARNESS_COMMANDS[name];
|
|
81
|
+
const parts = [program];
|
|
82
|
+
if (model)
|
|
83
|
+
parts.push(`--model ${shellQuote(model)}`);
|
|
84
|
+
if (effort)
|
|
85
|
+
parts.push(`--effort ${shellQuote(effort)}`);
|
|
86
|
+
return parts.join(' ');
|
|
87
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { makeHarnessTab, distinctColor, uniqueLabel } from '../tab/index.js';
|
|
2
|
+
import { parseHarnessCommand, HARNESS_COMMANDS, buildHarnessCommand } from './index.js';
|
|
3
|
+
import { isKnownModel } from './models.js';
|
|
4
|
+
import { HarnessScreenReader } from './screen.js';
|
|
5
|
+
import { HarnessRecorder } from './recorder.js';
|
|
6
|
+
import { HarnessAutoApprover } from './auto-approve.js';
|
|
7
|
+
import { busyStatusHandler } from './busy-status.js';
|
|
8
|
+
import { writeCaptureFile } from './capture-file.js';
|
|
9
|
+
import { messageBus } from '../bus.js';
|
|
10
|
+
import { notify } from '../notifications.js';
|
|
11
|
+
import { sandboxNotice } from '../sandbox/index.js';
|
|
12
|
+
// Owns harness command handling: launching a harness `<name>` as a PTY-backed tab (optionally in a
|
|
13
|
+
// fresh `--workspace` git clone, and optionally under a custom `as <label>`) and naming it uniquely.
|
|
14
|
+
// The controller owns the shared tab and PTY state; this module owns the harness-specific decisions
|
|
15
|
+
// and wiring.
|
|
16
|
+
export class HarnessManager {
|
|
17
|
+
managers;
|
|
18
|
+
screenReaders = new Map();
|
|
19
|
+
recorders = new Map();
|
|
20
|
+
autoApprovers = new Map();
|
|
21
|
+
constructor(managers) {
|
|
22
|
+
this.managers = managers;
|
|
23
|
+
messageBus.on('pty', 'exit', (event) => {
|
|
24
|
+
if (event.type !== 'exit')
|
|
25
|
+
return;
|
|
26
|
+
this.screenReaders.get(event.id)?.dispose();
|
|
27
|
+
this.screenReaders.delete(event.id);
|
|
28
|
+
this.recorders.get(event.id)?.dispose();
|
|
29
|
+
this.recorders.delete(event.id);
|
|
30
|
+
this.autoApprovers.delete(event.id);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// The named harness tab's most recent rendered-screen capture, or undefined when the tab is
|
|
34
|
+
// missing, is not a harness tab, or has no capture yet. Exposes the screen reader's rendered
|
|
35
|
+
// text (the coherent, de-ANSI'd form) to monitors without exposing the reader map.
|
|
36
|
+
latestScreenText(label) {
|
|
37
|
+
const tab = this.managers.tab.tabs.find((t) => t.label === label);
|
|
38
|
+
if (!tab?.harness)
|
|
39
|
+
return undefined;
|
|
40
|
+
return this.screenReaders.get(tab.harness.ptyId)?.latestCapture();
|
|
41
|
+
}
|
|
42
|
+
// Register a screen reader for a PTY this manager did not spawn itself (currently: ssh tabs,
|
|
43
|
+
// which reuse the harness-view tab shape but spawn their PTY directly via SshManager). No
|
|
44
|
+
// capture handler — auto-approve and busy detection are harness-specific and don't apply.
|
|
45
|
+
registerScreenReader(id) {
|
|
46
|
+
const dims = this.managers.pty.spawnDimensions();
|
|
47
|
+
this.screenReaders.set(id, new HarnessScreenReader(id, dims.cols, dims.rows));
|
|
48
|
+
}
|
|
49
|
+
// Handle a `harness <name> [as <label>] [-w] [--offline] [--model <name>] [--effort <level>]`
|
|
50
|
+
// command. Returns an error message to surface in the creator's transcript, or undefined once
|
|
51
|
+
// the harness tab has been opened.
|
|
52
|
+
run(input) {
|
|
53
|
+
const parsed = parseHarnessCommand(input);
|
|
54
|
+
if ('error' in parsed)
|
|
55
|
+
return parsed.error;
|
|
56
|
+
if ('capture' in parsed)
|
|
57
|
+
return this.capture(input, parsed.label);
|
|
58
|
+
if (parsed.model && !isKnownModel(parsed.name, parsed.model)) {
|
|
59
|
+
return `Unknown model "${parsed.model}" for harness "${parsed.name}" — add it to harness-models.json.`;
|
|
60
|
+
}
|
|
61
|
+
return this.open(parsed.name, parsed.workspace, parsed.offline, parsed.autoApprove, parsed.label, parsed.model, parsed.effort);
|
|
62
|
+
}
|
|
63
|
+
// Handle `harness capture <name>`: write the target tab's latest in-memory screen capture to a
|
|
64
|
+
// file under .janissary/captures/ and open it in a normal editor tab. Returns an error message
|
|
65
|
+
// to surface in the invoking tab's transcript, or undefined on success.
|
|
66
|
+
capture(input, label) {
|
|
67
|
+
const tab = this.managers.tab.tabs.find((t) => t.label === label);
|
|
68
|
+
if (!tab)
|
|
69
|
+
return `No tab labeled "${label}".`;
|
|
70
|
+
if (!tab.harness)
|
|
71
|
+
return `"${label}" is not a harness tab.`;
|
|
72
|
+
const latest = this.screenReaders.get(tab.harness.ptyId)?.latestCapture();
|
|
73
|
+
if (!latest)
|
|
74
|
+
return `No capture available for "${label}" yet.`;
|
|
75
|
+
const file = writeCaptureFile(label, latest.capturedAt, latest.text);
|
|
76
|
+
this.managers.openFile.edit(input, file, this.managers.tab.cur().label);
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
// Open (and focus) a harness tab running `name`, labeled `label` if given (otherwise `name`).
|
|
80
|
+
// With `workspace`, the harness starts in a fresh clone of the `origin` remote of the repo
|
|
81
|
+
// detected from cwd; otherwise it inherits the creator's cwd.
|
|
82
|
+
open(name, workspace, offline, autoApprove, label_, model, effort) {
|
|
83
|
+
const creator = this.managers.tab.cur();
|
|
84
|
+
const label = uniqueLabel(this.managers.tab.tabs, label_ ?? name);
|
|
85
|
+
const dir = this.parseDir(this.resolveCwd(workspace, label, this.managers.tab.cwdOf(creator.label) ?? process.cwd()));
|
|
86
|
+
if (typeof dir === 'string')
|
|
87
|
+
return dir;
|
|
88
|
+
const { cwd, workspaceDir } = dir;
|
|
89
|
+
const dotColor = distinctColor(this.managers.tab.tabs.map((t) => t.dotColor));
|
|
90
|
+
const group = creator?.group ?? 1;
|
|
91
|
+
const groupColor = creator?.groupColor ?? dotColor;
|
|
92
|
+
this.spawnTab(name, label, cwd, workspaceDir, offline, group, groupColor, dotColor, autoApprove, model, effort);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
// Open a harness tab for a profile entry: unlike `open()`, the group/color come from the
|
|
96
|
+
// profile launch (not the creator tab) and the starting directory comes from the entry's own
|
|
97
|
+
// `cwd`/`workspace` (falling back to the issuing tab's cwd when the entry has neither). Returns
|
|
98
|
+
// an error to report and skip on, or undefined once the tab is open. Never persisted — harness
|
|
99
|
+
// tabs have no agent state.
|
|
100
|
+
openFromProfile(entry, label, group, groupColor) {
|
|
101
|
+
const unique = uniqueLabel(this.managers.tab.tabs, label);
|
|
102
|
+
const dir = this.parseDir(this.resolveCwd(!!entry.workspace, unique, entry.cwd ?? process.cwd()));
|
|
103
|
+
if (typeof dir === 'string')
|
|
104
|
+
return dir;
|
|
105
|
+
const { cwd, workspaceDir } = dir;
|
|
106
|
+
const dotColor = distinctColor(this.managers.tab.tabs.map((t) => t.dotColor), entry.dotColor);
|
|
107
|
+
this.spawnTab(entry.harness, unique, cwd, workspaceDir, entry.offline ?? false, group, groupColor, dotColor, entry.autoApprove ?? false, entry.model, entry.effort);
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
// Shared core: create the harness tab, focus it, and spawn its PTY. `model`/`effort`, when
|
|
111
|
+
// given, are passed to the harness binary via `buildHarnessCommand`.
|
|
112
|
+
spawnTab(name, label, cwd, workspaceDir, offline, group, groupColor, dotColor, autoApprove, model, effort) {
|
|
113
|
+
const program = HARNESS_COMMANDS[name];
|
|
114
|
+
const harness = { name, program, ptyId: '', status: 'running' };
|
|
115
|
+
const tab = makeHarnessTab(label, dotColor, this.managers.tab.tabs.length + 1, group, groupColor, harness, workspaceDir);
|
|
116
|
+
tab.offline = offline;
|
|
117
|
+
tab.autoApprove = autoApprove;
|
|
118
|
+
this.managers.tab.insertTabInGroup(tab);
|
|
119
|
+
this.managers.tab.setCwd(label, cwd);
|
|
120
|
+
this.managers.tab.addBusy(label);
|
|
121
|
+
this.managers.tab.activeTab = this.managers.tab.findIndex(tab.label);
|
|
122
|
+
const id = this.managers.pty.spawn(label, program, buildHarnessCommand(name, model, effort), cwd, workspaceDir, offline);
|
|
123
|
+
const dims = this.managers.pty.spawnDimensions();
|
|
124
|
+
this.screenReaders.set(id, new HarnessScreenReader(id, dims.cols, dims.rows, this.captureHandler(name, label, id, autoApprove)));
|
|
125
|
+
this.recorders.set(id, new HarnessRecorder(id, label, program, dims.cols, dims.rows));
|
|
126
|
+
const liveTab = this.managers.tab.tabs.find((t) => t.label === label);
|
|
127
|
+
if (liveTab?.harness)
|
|
128
|
+
liveTab.harness.ptyId = id;
|
|
129
|
+
const notice = workspaceDir ? sandboxNotice() : undefined;
|
|
130
|
+
if (notice)
|
|
131
|
+
this.managers.tab.append(label, { input: '', output: notice });
|
|
132
|
+
messageBus.emit('state', { type: 'dirty' });
|
|
133
|
+
}
|
|
134
|
+
// Build the screen-reader callback that feeds each fresh capture to whichever consumers apply:
|
|
135
|
+
// the auto-approver (when `autoApprove` is on) and the busy/ready status handler (when the
|
|
136
|
+
// harness has a detector). The approver runs first so the busy handler reads its stuck state as
|
|
137
|
+
// of the same capture. Returns undefined when neither applies, so the reader runs exactly as it
|
|
138
|
+
// would with no consumers.
|
|
139
|
+
captureHandler(name, label, id, autoApprove) {
|
|
140
|
+
const approver = this.buildAutoApprover(name, label, id, autoApprove);
|
|
141
|
+
const busyHandler = busyStatusHandler(name, label, this.managers, approver);
|
|
142
|
+
if (!approver && !busyHandler)
|
|
143
|
+
return undefined;
|
|
144
|
+
return (capture) => {
|
|
145
|
+
approver?.onCapture(capture);
|
|
146
|
+
busyHandler?.(capture);
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// When `autoApprove` is on, build the tab's auto-approver and register it under the PTY id;
|
|
150
|
+
// otherwise return undefined. The approver injects the approval keystroke back into this PTY
|
|
151
|
+
// and reports each approval to the notifications feed (label-free — `notify` prefixes the tab
|
|
152
|
+
// label).
|
|
153
|
+
buildAutoApprover(name, label, id, autoApprove) {
|
|
154
|
+
if (!autoApprove)
|
|
155
|
+
return undefined;
|
|
156
|
+
const approver = new HarnessAutoApprover({
|
|
157
|
+
harnessName: name,
|
|
158
|
+
approve: (keystroke) => this.managers.pty.input(id, keystroke),
|
|
159
|
+
notify: (message) => notify(this.managers, 'auto-approve', label, message),
|
|
160
|
+
});
|
|
161
|
+
this.autoApprovers.set(id, approver);
|
|
162
|
+
return approver;
|
|
163
|
+
}
|
|
164
|
+
// Parse `resolveCwd`'s result into a clean `{ cwd, workspaceDir }` or return the error string.
|
|
165
|
+
parseDir(resolved) {
|
|
166
|
+
if (typeof resolved !== 'string' && 'error' in resolved)
|
|
167
|
+
return resolved.error;
|
|
168
|
+
return {
|
|
169
|
+
cwd: typeof resolved === 'string' ? resolved : resolved.dir,
|
|
170
|
+
workspaceDir: typeof resolved === 'string' ? undefined : resolved.dir,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// The harness's starting directory: a new workspace clone (with `workspace`) or `fallbackCwd`.
|
|
174
|
+
// Returns the directory, or an `{ error }` to surface when there's no repo or the clone fails.
|
|
175
|
+
// A workspace clone is returned as `{ dir }` (not a bare string) so the caller can tell it apart
|
|
176
|
+
// from the fallback cwd and record it on the tab for cleanup on close.
|
|
177
|
+
resolveCwd(workspace, label, fallbackCwd) {
|
|
178
|
+
if (!workspace)
|
|
179
|
+
return fallbackCwd;
|
|
180
|
+
return this.managers.workspace.create(label);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import catalog from '../../harness-models.json' with { type: 'json' };
|
|
2
|
+
const models = catalog;
|
|
3
|
+
export function modelsFor(harness) {
|
|
4
|
+
return models[harness] ?? [];
|
|
5
|
+
}
|
|
6
|
+
export function isKnownModel(harness, model) {
|
|
7
|
+
return modelsFor(harness).includes(model);
|
|
8
|
+
}
|