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.
Files changed (268) hide show
  1. package/LICENSE +75 -0
  2. package/README.md +28 -0
  3. package/agent-names.json +52 -0
  4. package/bin/janus.mjs +27 -0
  5. package/chrome-extension/_metadata/generated_indexed_rulesets/_ruleset1 +0 -0
  6. package/chrome-extension/content-script.js +62 -0
  7. package/chrome-extension/manifest.json +25 -0
  8. package/chrome-extension/rules.json +16 -0
  9. package/dist/acp/index.js +93 -0
  10. package/dist/acp/loop.js +75 -0
  11. package/dist/acp/manager.js +126 -0
  12. package/dist/acp/rate-limit.js +8 -0
  13. package/dist/acp/runner.js +18 -0
  14. package/dist/acp/tools.js +27 -0
  15. package/dist/acp-loop.js +75 -0
  16. package/dist/acp-manager.js +109 -0
  17. package/dist/acp-runner.js +18 -0
  18. package/dist/acp.js +91 -0
  19. package/dist/agent/commands.js +29 -0
  20. package/dist/agent/communication-manager.js +71 -0
  21. package/dist/agent/state.js +58 -0
  22. package/dist/agent-commands.js +29 -0
  23. package/dist/agent-communication-manager.js +71 -0
  24. package/dist/agent-state.js +58 -0
  25. package/dist/app-themes.js +13 -0
  26. package/dist/browser/command.js +104 -0
  27. package/dist/browser/index.js +124 -0
  28. package/dist/browser/tab-helpers.js +51 -0
  29. package/dist/browser/tab.js +99 -0
  30. package/dist/browser-command.js +104 -0
  31. package/dist/browser-tab-helpers.js +51 -0
  32. package/dist/browser-tab.js +99 -0
  33. package/dist/browser.js +124 -0
  34. package/dist/buffer.js +42 -0
  35. package/dist/bus.js +52 -0
  36. package/dist/capture/manager.js +48 -0
  37. package/dist/capture/router.js +18 -0
  38. package/dist/capture-manager.js +48 -0
  39. package/dist/capture-router.js +20 -0
  40. package/dist/chrome-extension-loader.js +62 -0
  41. package/dist/cli-args.js +80 -0
  42. package/dist/command-manager.js +126 -0
  43. package/dist/command-router.js +14 -0
  44. package/dist/commands/acp-reset.js +13 -0
  45. package/dist/commands/acp.js +5 -0
  46. package/dist/commands/agent.js +5 -0
  47. package/dist/commands/broadcast.js +22 -0
  48. package/dist/commands/browser.js +5 -0
  49. package/dist/commands/clear.js +5 -0
  50. package/dist/commands/close.js +42 -0
  51. package/dist/commands/connection.js +5 -0
  52. package/dist/commands/db.js +5 -0
  53. package/dist/commands/edit.js +18 -0
  54. package/dist/commands/files.js +13 -0
  55. package/dist/commands/hist.js +7 -0
  56. package/dist/commands/index.js +62 -0
  57. package/dist/commands/monitor.js +61 -0
  58. package/dist/commands/msg.js +17 -0
  59. package/dist/commands/next.js +7 -0
  60. package/dist/commands/notifications.js +16 -0
  61. package/dist/commands/notify.js +20 -0
  62. package/dist/commands/open.js +41 -0
  63. package/dist/commands/profile.js +5 -0
  64. package/dist/commands/queue.js +37 -0
  65. package/dist/commands/quit.js +6 -0
  66. package/dist/commands/rename.js +12 -0
  67. package/dist/commands/resolve-target.js +12 -0
  68. package/dist/commands/schedule.js +74 -0
  69. package/dist/commands/search.js +26 -0
  70. package/dist/commands/send.js +51 -0
  71. package/dist/commands/state.js +9 -0
  72. package/dist/commands/syntax.js +36 -0
  73. package/dist/commands/tasks.js +7 -0
  74. package/dist/commands/theme.js +41 -0
  75. package/dist/commands/types.js +1 -0
  76. package/dist/commands.js +70 -0
  77. package/dist/completion/browser.js +24 -0
  78. package/dist/completion/fs.js +26 -0
  79. package/dist/completion/handlers.js +81 -0
  80. package/dist/completion/helpers.js +43 -0
  81. package/dist/completion/index.js +43 -0
  82. package/dist/completion-browser.js +24 -0
  83. package/dist/completion-fs.js +26 -0
  84. package/dist/completion-handlers.js +69 -0
  85. package/dist/completion-helpers.js +43 -0
  86. package/dist/completion.js +42 -0
  87. package/dist/config.js +66 -0
  88. package/dist/connection/close.js +41 -0
  89. package/dist/connection/manager.js +93 -0
  90. package/dist/connection/parsing.js +28 -0
  91. package/dist/connection-close.js +41 -0
  92. package/dist/connection-manager.js +93 -0
  93. package/dist/connection-parsing.js +28 -0
  94. package/dist/connections.js +75 -0
  95. package/dist/controller-events.js +34 -0
  96. package/dist/controller-file-tree.js +36 -0
  97. package/dist/controller.js +201 -0
  98. package/dist/database/index.js +83 -0
  99. package/dist/database/manager.js +67 -0
  100. package/dist/database/parsing.js +54 -0
  101. package/dist/database/query.js +41 -0
  102. package/dist/database-manager.js +67 -0
  103. package/dist/database-parsing.js +54 -0
  104. package/dist/database-query.js +41 -0
  105. package/dist/database.js +83 -0
  106. package/dist/datetime.js +15 -0
  107. package/dist/editor/save.js +27 -0
  108. package/dist/editor/sync.js +10 -0
  109. package/dist/editor/watch-manager.js +84 -0
  110. package/dist/editor-save.js +24 -0
  111. package/dist/editor-watch-manager.js +84 -0
  112. package/dist/file-tree-args.js +36 -0
  113. package/dist/file-tree-git-refresh.js +33 -0
  114. package/dist/file-tree-manager.js +252 -0
  115. package/dist/file-tree-moves.js +26 -0
  116. package/dist/file-tree-open.js +56 -0
  117. package/dist/file-tree-rebuild.js +22 -0
  118. package/dist/file-tree-watch.js +27 -0
  119. package/dist/file-tree.js +82 -0
  120. package/dist/git-status.js +64 -0
  121. package/dist/github-token.js +19 -0
  122. package/dist/global-history.js +48 -0
  123. package/dist/harness/auto-approve.js +79 -0
  124. package/dist/harness/busy-status.js +91 -0
  125. package/dist/harness/capture-file.js +28 -0
  126. package/dist/harness/index.js +87 -0
  127. package/dist/harness/manager.js +182 -0
  128. package/dist/harness/models.js +8 -0
  129. package/dist/harness/recorder.js +89 -0
  130. package/dist/harness/recording-file.js +25 -0
  131. package/dist/harness/screen.js +79 -0
  132. package/dist/harness-capture-file.js +28 -0
  133. package/dist/harness-manager.js +112 -0
  134. package/dist/harness-models.js +8 -0
  135. package/dist/harness-screen.js +72 -0
  136. package/dist/harness.js +54 -0
  137. package/dist/index.js +163 -0
  138. package/dist/instance-lock.js +34 -0
  139. package/dist/interactive.js +33 -0
  140. package/dist/main.js +203 -0
  141. package/dist/managers.js +1 -0
  142. package/dist/message-handler.js +134 -0
  143. package/dist/messaging.js +47 -0
  144. package/dist/monitor/acp.js +24 -0
  145. package/dist/monitor/ask.js +31 -0
  146. package/dist/monitor/context.js +27 -0
  147. package/dist/monitor/editor-feed.js +49 -0
  148. package/dist/monitor/feed-diff.js +22 -0
  149. package/dist/monitor/feeds.js +19 -0
  150. package/dist/monitor/framing.js +29 -0
  151. package/dist/monitor/harness-feed.js +21 -0
  152. package/dist/monitor/info.js +29 -0
  153. package/dist/monitor/manager.js +221 -0
  154. package/dist/monitor/page-feed.js +21 -0
  155. package/dist/monitor/parsing.js +82 -0
  156. package/dist/monitor/reply.js +11 -0
  157. package/dist/monitor/session.js +36 -0
  158. package/dist/monitor/stop.js +33 -0
  159. package/dist/monitor/suggestion.js +12 -0
  160. package/dist/monitor/targets.js +69 -0
  161. package/dist/monitor/window.js +109 -0
  162. package/dist/monitor-acp.js +22 -0
  163. package/dist/monitor-info.js +18 -0
  164. package/dist/monitor-manager.js +233 -0
  165. package/dist/monitor-parsing.js +68 -0
  166. package/dist/monitor-session.js +19 -0
  167. package/dist/monitor-targets.js +37 -0
  168. package/dist/monitor-window.js +81 -0
  169. package/dist/notifications-tab.js +32 -0
  170. package/dist/notifications.js +87 -0
  171. package/dist/open-file-manager.js +100 -0
  172. package/dist/openers/editor.js +41 -0
  173. package/dist/openers/external-viewer.js +11 -0
  174. package/dist/openers/image.js +27 -0
  175. package/dist/openers/index.js +16 -0
  176. package/dist/openers/markdown.js +20 -0
  177. package/dist/openers/os-open.js +24 -0
  178. package/dist/openers/page.js +51 -0
  179. package/dist/openers/size.js +13 -0
  180. package/dist/openers/types.js +1 -0
  181. package/dist/page/sync.js +10 -0
  182. package/dist/paths.js +45 -0
  183. package/dist/persona-parsing.js +49 -0
  184. package/dist/personas.js +44 -0
  185. package/dist/profile/agent-opener.js +154 -0
  186. package/dist/profile/files.js +17 -0
  187. package/dist/profile/manager.js +72 -0
  188. package/dist/profile/monitors.js +23 -0
  189. package/dist/profile/notifications.js +11 -0
  190. package/dist/profile-agent-opener.js +136 -0
  191. package/dist/profile-manager.js +72 -0
  192. package/dist/profiles.js +143 -0
  193. package/dist/protocol.js +1 -0
  194. package/dist/pseudoterminal-manager.js +106 -0
  195. package/dist/pty.js +41 -0
  196. package/dist/recognizers/acp.js +38 -0
  197. package/dist/recognizers/analyze.js +49 -0
  198. package/dist/recognizers/bash.js +51 -0
  199. package/dist/recognizers/db.js +26 -0
  200. package/dist/recognizers/index.js +1 -0
  201. package/dist/recognizers/lexicon.js +11 -0
  202. package/dist/recognizers/types.js +5 -0
  203. package/dist/resolve.js +39 -0
  204. package/dist/route-choice.js +11 -0
  205. package/dist/sandbox/index.js +217 -0
  206. package/dist/sandbox/paths.js +178 -0
  207. package/dist/sandbox/profile.js +161 -0
  208. package/dist/sandbox-paths.js +159 -0
  209. package/dist/sandbox-profile.js +161 -0
  210. package/dist/sandbox.js +217 -0
  211. package/dist/schedule/display.js +19 -0
  212. package/dist/schedule/helpers.js +76 -0
  213. package/dist/schedule/index.js +76 -0
  214. package/dist/schedule/manager.js +101 -0
  215. package/dist/schedule/parsing.js +63 -0
  216. package/dist/schedule/time.js +34 -0
  217. package/dist/schedule-display.js +19 -0
  218. package/dist/schedule-helpers.js +76 -0
  219. package/dist/schedule-manager.js +98 -0
  220. package/dist/schedule-parsing.js +63 -0
  221. package/dist/schedule-time.js +34 -0
  222. package/dist/schedule.js +76 -0
  223. package/dist/search-matches.js +40 -0
  224. package/dist/security.js +44 -0
  225. package/dist/shell-manager.js +139 -0
  226. package/dist/shell.js +59 -0
  227. package/dist/ssh-manager.js +42 -0
  228. package/dist/ssh.js +39 -0
  229. package/dist/startup-errors.js +21 -0
  230. package/dist/state-event.js +14 -0
  231. package/dist/state-format.js +38 -0
  232. package/dist/syntax-themes.js +14 -0
  233. package/dist/tab/agent-state.js +19 -0
  234. package/dist/tab/cleanup.js +33 -0
  235. package/dist/tab/colors.js +43 -0
  236. package/dist/tab/creators.js +95 -0
  237. package/dist/tab/dock.js +35 -0
  238. package/dist/tab/focus-history.js +41 -0
  239. package/dist/tab/formatting-handlers.js +77 -0
  240. package/dist/tab/formatting.js +21 -0
  241. package/dist/tab/history.js +14 -0
  242. package/dist/tab/index.js +65 -0
  243. package/dist/tab/manager.js +329 -0
  244. package/dist/tab/openers.js +35 -0
  245. package/dist/tab/queue.js +33 -0
  246. package/dist/tab/rehydrate.js +15 -0
  247. package/dist/tab/reorder.js +15 -0
  248. package/dist/tab/transcript.js +15 -0
  249. package/dist/tab/utils.js +55 -0
  250. package/dist/tab/view.js +42 -0
  251. package/dist/tab-cleanup.js +27 -0
  252. package/dist/tab-colors.js +43 -0
  253. package/dist/tab-creators.js +85 -0
  254. package/dist/tab-formatting-handlers.js +77 -0
  255. package/dist/tab-formatting.js +21 -0
  256. package/dist/tab-manager.js +418 -0
  257. package/dist/tab-utils.js +55 -0
  258. package/dist/tab.js +57 -0
  259. package/dist/tasks.js +32 -0
  260. package/dist/transcript/logger.js +36 -0
  261. package/dist/transcript/store.js +66 -0
  262. package/dist/types.js +6 -0
  263. package/dist/user-agent.js +65 -0
  264. package/dist/word-wrapping.js +42 -0
  265. package/dist/workspace-manager.js +40 -0
  266. package/dist/workspace.js +120 -0
  267. package/help.md +97 -0
  268. 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
+ }