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,89 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { messageBus } from '../bus.js';
3
+ import { ensureRecordingDirectory, harnessRecordingPath } from './recording-file.js';
4
+ // The terminal name the PTY is actually spawned with (`src/pty.ts`); asciicast players read it for
5
+ // correct rendering.
6
+ const TERM = 'xterm-256color';
7
+ // Records one harness PTY's byte stream to a replayable asciicast v2 `.cast` file. It observes the
8
+ // same `pty` bus events as `HarnessScreenReader` (its sibling observer of the same bytes) and, like
9
+ // it, is owned/disposed by `HarnessManager`. The file is created lazily on the first `data` event —
10
+ // a harness that exits before producing output leaves no empty file. Uses a single long-lived
11
+ // append stream (not per-event `appendFileSync`) so a burst of PTY output never blocks `bus.emit`.
12
+ export class HarnessRecorder {
13
+ id;
14
+ label;
15
+ program;
16
+ cols;
17
+ rows;
18
+ subscription;
19
+ stream;
20
+ startedAt = Date.now();
21
+ disposed = false;
22
+ failed = false;
23
+ constructor(id, label, program, cols, rows) {
24
+ this.id = id;
25
+ this.label = label;
26
+ this.program = program;
27
+ this.cols = cols;
28
+ this.rows = rows;
29
+ this.subscription = messageBus.on('pty', ['data', 'exit', 'resize'], (event) => {
30
+ if (event.id !== this.id)
31
+ return;
32
+ if (event.type === 'data')
33
+ this.onData(event.data);
34
+ else if (event.type === 'resize')
35
+ this.onResize(event.cols, event.rows);
36
+ else
37
+ this.dispose();
38
+ });
39
+ }
40
+ dispose() {
41
+ if (this.disposed)
42
+ return;
43
+ this.disposed = true;
44
+ this.subscription.unsubscribe();
45
+ this.stream?.end();
46
+ this.stream = undefined;
47
+ }
48
+ onData(data) {
49
+ if (this.disposed || this.failed)
50
+ return;
51
+ if (!this.stream)
52
+ this.open();
53
+ this.writeEvent('o', data);
54
+ }
55
+ // Track the latest dimensions always; emit an `"r"` event only once the file is open. A resize
56
+ // arriving before the first output just updates the pending header dimensions.
57
+ onResize(cols, rows) {
58
+ this.cols = cols;
59
+ this.rows = rows;
60
+ if (this.disposed || this.failed || !this.stream)
61
+ return;
62
+ this.writeEvent('r', `${cols}x${rows}`);
63
+ }
64
+ // Lazily open the append stream and write the asciicast v2 header line.
65
+ open() {
66
+ ensureRecordingDirectory();
67
+ const stream = createWriteStream(harnessRecordingPath(this.label, this.startedAt), { flags: 'a' });
68
+ // A Node stream's async `'error'` event escapes the bus's per-listener try/catch and would
69
+ // crash the process if unhandled — disable the recorder instead.
70
+ stream.on('error', () => { this.failed = true; });
71
+ this.stream = stream;
72
+ const header = {
73
+ version: 2,
74
+ width: this.cols,
75
+ height: this.rows,
76
+ timestamp: Math.floor(this.startedAt / 1000),
77
+ command: this.program,
78
+ title: this.label,
79
+ env: { TERM },
80
+ };
81
+ stream.write(JSON.stringify(header) + '\n');
82
+ }
83
+ writeEvent(code, data) {
84
+ if (this.failed || !this.stream)
85
+ return;
86
+ const elapsed = Math.round(((Date.now() - this.startedAt) / 1000) * 1e6) / 1e6;
87
+ this.stream.write(JSON.stringify([elapsed, code, data]) + '\n');
88
+ }
89
+ }
@@ -0,0 +1,25 @@
1
+ import { mkdirSync, rmSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ let recordingDirectory = '';
4
+ export function initHarnessRecordingDirectory(projectDirectory) {
5
+ recordingDirectory = path.join(projectDirectory, '.janissary', 'recordings');
6
+ }
7
+ export function ensureRecordingDirectory() {
8
+ mkdirSync(recordingDirectory, { recursive: true });
9
+ }
10
+ // The absolute `.cast` path for a session started at `startedAt`. The label is sanitized rather
11
+ // than rejected (the tab exists, so its label is legitimate even with filename-hostile characters),
12
+ // and the ISO timestamp's `:`/`.` are replaced with `-`, mirroring `writeCaptureFile`.
13
+ export function harnessRecordingPath(label, startedAt) {
14
+ const safeLabel = label.replaceAll(/[^\w-]/g, '-');
15
+ const timestamp = new Date(startedAt).toISOString().replaceAll(/[:.]/g, '-');
16
+ return path.join(recordingDirectory, `${safeLabel}-${timestamp}.cast`);
17
+ }
18
+ export function clearHarnessRecordingDirectory() {
19
+ if (!recordingDirectory)
20
+ return;
21
+ try {
22
+ rmSync(recordingDirectory, { recursive: true, force: true });
23
+ }
24
+ catch { /* ignore */ }
25
+ }
@@ -0,0 +1,79 @@
1
+ import xterm from '@xterm/headless';
2
+ import { messageBus } from '../bus.js';
3
+ // `@xterm/headless` ships a CommonJS bundle whose named exports Node's ESM loader cannot detect
4
+ // statically — the module namespace it synthesizes holds only `default`. So `import { Terminal }`
5
+ // type-checks but throws SyntaxError under Node, while `import Terminal` binds the whole
6
+ // `module.exports` object (not the class) and fails as "default is not a constructor". Take the
7
+ // class off the default import, which Node sets to `module.exports`, and keep the named import
8
+ // type-only so it erases at compile time.
9
+ const { Terminal: HeadlessTerminal } = xterm;
10
+ // Delay between a PTY byte arriving and the screen being read: long enough for a burst of output
11
+ // to settle into a coherent frame, short enough that the capture reflects "now".
12
+ const CAPTURE_DELAY_MS = 1000;
13
+ // Mirrors one harness PTY into a headless terminal so its on-screen text can be read server-side.
14
+ // Captures are throttled on activity: the first `data` event schedules a read 1s later, further
15
+ // events in that window neither reschedule nor extend it, and an idle PTY schedules nothing at
16
+ // all — so an unchanged screen is never re-captured. Only the latest capture is kept.
17
+ export class HarnessScreenReader {
18
+ id;
19
+ onCapture;
20
+ term;
21
+ subscription;
22
+ pending;
23
+ capture;
24
+ title;
25
+ disposed = false;
26
+ constructor(id, cols, rows, onCapture) {
27
+ this.id = id;
28
+ this.onCapture = onCapture;
29
+ // allowProposedApi: the headless build gates the `buffer` read API behind it.
30
+ this.term = new HeadlessTerminal({ cols, rows, scrollback: 0, allowProposedApi: true });
31
+ // The terminal parses OSC 0/2 title sequences itself; retain the latest so each capture can
32
+ // carry the title alongside the rendered text (harness busy/ready detection reads it).
33
+ this.term.onTitleChange((title) => { this.title = title; });
34
+ this.subscription = messageBus.on('pty', ['data', 'exit', 'resize'], (event) => {
35
+ if (event.id !== this.id)
36
+ return;
37
+ if (event.type === 'data')
38
+ this.onData(event.data);
39
+ else if (event.type === 'resize')
40
+ this.term.resize(event.cols, event.rows);
41
+ else
42
+ this.dispose();
43
+ });
44
+ }
45
+ // The most recent capture, or undefined if the PTY has never been quiet 1s after output.
46
+ latestCapture() { return this.capture; }
47
+ dispose() {
48
+ if (this.disposed)
49
+ return;
50
+ this.disposed = true;
51
+ if (this.pending !== undefined)
52
+ clearTimeout(this.pending);
53
+ this.pending = undefined;
54
+ this.subscription.unsubscribe();
55
+ this.term.dispose();
56
+ }
57
+ onData(data) {
58
+ this.term.write(data);
59
+ if (this.pending !== undefined)
60
+ return;
61
+ this.pending = setTimeout(() => {
62
+ this.pending = undefined;
63
+ // xterm parses write() input asynchronously; read the buffer only after the queue drains.
64
+ this.term.write('', () => { if (!this.disposed)
65
+ this.captureNow(); });
66
+ }, CAPTURE_DELAY_MS);
67
+ }
68
+ captureNow() {
69
+ const buffer = this.term.buffer.active;
70
+ const lines = [];
71
+ for (let i = 0; i < buffer.length; i++) {
72
+ lines.push(buffer.getLine(i)?.translateToString(true) ?? '');
73
+ }
74
+ while (lines.length > 0 && lines.at(-1) === '')
75
+ lines.pop();
76
+ this.capture = { text: lines.join('\n'), capturedAt: Date.now(), title: this.title };
77
+ this.onCapture?.(this.capture);
78
+ }
79
+ }
@@ -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,112 @@
1
+ import { makeHarnessTab, distinctColor, uniqueLabel } from './tab.js';
2
+ import { parseHarnessCommand, HARNESS_COMMANDS, buildHarnessCommand } from './harness.js';
3
+ import { HarnessScreenReader } from './harness-screen.js';
4
+ import { writeCaptureFile } from './harness-capture-file.js';
5
+ import { messageBus } from './bus.js';
6
+ import { sandboxNotice } from './sandbox.js';
7
+ // Owns harness command handling: launching a harness `<name>` as a PTY-backed tab (optionally in a
8
+ // fresh `--workspace` git clone, and optionally under a custom `as <label>`) and naming it uniquely.
9
+ // The controller owns the shared tab and PTY state; this module owns the harness-specific decisions
10
+ // and wiring.
11
+ export class HarnessManager {
12
+ managers;
13
+ screenReaders = new Map();
14
+ constructor(managers) {
15
+ this.managers = managers;
16
+ messageBus.on('pty', 'exit', (event) => {
17
+ if (event.type !== 'exit')
18
+ return;
19
+ this.screenReaders.get(event.id)?.dispose();
20
+ this.screenReaders.delete(event.id);
21
+ });
22
+ }
23
+ // Handle a `harness <name> [as <label>] [-w] [--offline]` command. Returns an error message to
24
+ // surface in the creator's transcript, or undefined once the harness tab has been opened.
25
+ run(input) {
26
+ const parsed = parseHarnessCommand(input);
27
+ if ('error' in parsed)
28
+ return parsed.error;
29
+ if ('capture' in parsed)
30
+ return this.capture(input, parsed.label);
31
+ return this.open(parsed.name, parsed.workspace, parsed.offline, parsed.label);
32
+ }
33
+ // Handle `harness capture <name>`: write the target tab's latest in-memory screen capture to a
34
+ // file under .janissary/captures/ and open it in a normal editor tab. Returns an error message
35
+ // to surface in the invoking tab's transcript, or undefined on success.
36
+ capture(input, label) {
37
+ const tab = this.managers.tab.tabs.find((t) => t.label === label);
38
+ if (!tab)
39
+ return `No tab labeled "${label}".`;
40
+ if (!tab.harness)
41
+ return `"${label}" is not a harness tab.`;
42
+ const latest = this.screenReaders.get(tab.harness.ptyId)?.latestCapture();
43
+ if (!latest)
44
+ return `No capture available for "${label}" yet.`;
45
+ const file = writeCaptureFile(label, latest.capturedAt, latest.text);
46
+ this.managers.openFile.edit(input, file, this.managers.tab.cur().label);
47
+ return undefined;
48
+ }
49
+ // Open (and focus) a harness tab running `name`, labeled `label` if given (otherwise `name`).
50
+ // With `workspace`, the harness starts in a fresh clone of the `origin` remote of the repo
51
+ // detected from cwd; otherwise it inherits the creator's cwd.
52
+ open(name, workspace, offline, label_) {
53
+ const creator = this.managers.tab.cur();
54
+ const label = uniqueLabel(this.managers.tab.tabs, label_ ?? name);
55
+ const resolved = this.resolveCwd(workspace, label, this.managers.tab.cwdOf(creator.label) ?? process.cwd());
56
+ if (typeof resolved !== 'string' && 'error' in resolved)
57
+ return resolved.error;
58
+ const cwd = typeof resolved === 'string' ? resolved : resolved.dir;
59
+ const workspaceDir = typeof resolved === 'string' ? undefined : resolved.dir;
60
+ const dotColor = distinctColor(this.managers.tab.tabs.map((t) => t.dotColor));
61
+ const group = creator?.group ?? 1;
62
+ const groupColor = creator?.groupColor ?? dotColor;
63
+ this.spawnTab(name, label, cwd, workspaceDir, offline, group, groupColor, dotColor);
64
+ return undefined;
65
+ }
66
+ // Open a harness tab for a profile entry: unlike `open()`, the group/color come from the
67
+ // profile launch (not the creator tab) and the starting directory comes from the entry's own
68
+ // `cwd`/`workspace` (falling back to the issuing tab's cwd when the entry has neither). Returns
69
+ // an error to report and skip on, or undefined once the tab is open. Never persisted — harness
70
+ // tabs have no agent state.
71
+ openFromProfile(entry, label, group, groupColor) {
72
+ const unique = uniqueLabel(this.managers.tab.tabs, label);
73
+ const resolved = this.resolveCwd(!!entry.workspace, unique, entry.cwd ?? process.cwd());
74
+ if (typeof resolved !== 'string' && 'error' in resolved)
75
+ return resolved.error;
76
+ const cwd = typeof resolved === 'string' ? resolved : resolved.dir;
77
+ const workspaceDir = typeof resolved === 'string' ? undefined : resolved.dir;
78
+ const dotColor = distinctColor(this.managers.tab.tabs.map((t) => t.dotColor), entry.dotColor);
79
+ this.spawnTab(entry.harness, unique, cwd, workspaceDir, false, group, groupColor, dotColor, entry.model);
80
+ return undefined;
81
+ }
82
+ // Shared core: create the harness tab, focus it, and spawn its PTY. `model`, when given, is
83
+ // passed to the harness binary via `buildHarnessCommand`.
84
+ spawnTab(name, label, cwd, workspaceDir, offline, group, groupColor, dotColor, model) {
85
+ const program = HARNESS_COMMANDS[name];
86
+ const harness = { name, program, ptyId: '', status: 'running' };
87
+ const tab = makeHarnessTab(label, dotColor, this.managers.tab.tabs.length + 1, group, groupColor, harness, workspaceDir);
88
+ tab.offline = offline;
89
+ this.managers.tab.insertTabInGroup(tab);
90
+ this.managers.tab.addBusy(label);
91
+ this.managers.tab.activeTab = this.managers.tab.findIndex(tab.label);
92
+ const id = this.managers.pty.spawn(label, program, buildHarnessCommand(name, model), cwd, workspaceDir, offline);
93
+ const dims = this.managers.pty.spawnDimensions();
94
+ this.screenReaders.set(id, new HarnessScreenReader(id, dims.cols, dims.rows));
95
+ const liveTab = this.managers.tab.tabs.find((t) => t.label === label);
96
+ if (liveTab?.harness)
97
+ liveTab.harness.ptyId = id;
98
+ const notice = workspaceDir ? sandboxNotice() : undefined;
99
+ if (notice)
100
+ this.managers.tab.append(label, { input: '', output: notice });
101
+ messageBus.emit('state', { type: 'dirty' });
102
+ }
103
+ // The harness's starting directory: a new workspace clone (with `workspace`) or `fallbackCwd`.
104
+ // Returns the directory, or an `{ error }` to surface when there's no repo or the clone fails.
105
+ // A workspace clone is returned as `{ dir }` (not a bare string) so the caller can tell it apart
106
+ // from the fallback cwd and record it on the tab for cleanup on close.
107
+ resolveCwd(workspace, label, fallbackCwd) {
108
+ if (!workspace)
109
+ return fallbackCwd;
110
+ return this.managers.workspace.create(label);
111
+ }
112
+ }
@@ -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
+ }
@@ -0,0 +1,72 @@
1
+ import xterm from '@xterm/headless';
2
+ import { messageBus } from './bus.js';
3
+ // `@xterm/headless` ships a CommonJS bundle whose named exports Node's ESM loader cannot detect
4
+ // statically — the module namespace it synthesizes holds only `default`. So `import { Terminal }`
5
+ // type-checks but throws SyntaxError under Node, while `import Terminal` binds the whole
6
+ // `module.exports` object (not the class) and fails as "default is not a constructor". Take the
7
+ // class off the default import, which Node sets to `module.exports`, and keep the named import
8
+ // type-only so it erases at compile time.
9
+ const { Terminal: HeadlessTerminal } = xterm;
10
+ // Delay between a PTY byte arriving and the screen being read: long enough for a burst of output
11
+ // to settle into a coherent frame, short enough that the capture reflects "now".
12
+ const CAPTURE_DELAY_MS = 1000;
13
+ // Mirrors one harness PTY into a headless terminal so its on-screen text can be read server-side.
14
+ // Captures are throttled on activity: the first `data` event schedules a read 1s later, further
15
+ // events in that window neither reschedule nor extend it, and an idle PTY schedules nothing at
16
+ // all — so an unchanged screen is never re-captured. Only the latest capture is kept.
17
+ export class HarnessScreenReader {
18
+ id;
19
+ term;
20
+ subscription;
21
+ pending;
22
+ capture;
23
+ disposed = false;
24
+ constructor(id, cols, rows) {
25
+ this.id = id;
26
+ // allowProposedApi: the headless build gates the `buffer` read API behind it.
27
+ this.term = new HeadlessTerminal({ cols, rows, scrollback: 0, allowProposedApi: true });
28
+ this.subscription = messageBus.on('pty', ['data', 'exit', 'resize'], (event) => {
29
+ if (event.id !== this.id)
30
+ return;
31
+ if (event.type === 'data')
32
+ this.onData(event.data);
33
+ else if (event.type === 'resize')
34
+ this.term.resize(event.cols, event.rows);
35
+ else
36
+ this.dispose();
37
+ });
38
+ }
39
+ // The most recent capture, or undefined if the PTY has never been quiet 1s after output.
40
+ latestCapture() { return this.capture; }
41
+ dispose() {
42
+ if (this.disposed)
43
+ return;
44
+ this.disposed = true;
45
+ if (this.pending !== undefined)
46
+ clearTimeout(this.pending);
47
+ this.pending = undefined;
48
+ this.subscription.unsubscribe();
49
+ this.term.dispose();
50
+ }
51
+ onData(data) {
52
+ this.term.write(data);
53
+ if (this.pending !== undefined)
54
+ return;
55
+ this.pending = setTimeout(() => {
56
+ this.pending = undefined;
57
+ // xterm parses write() input asynchronously; read the buffer only after the queue drains.
58
+ this.term.write('', () => { if (!this.disposed)
59
+ this.captureNow(); });
60
+ }, CAPTURE_DELAY_MS);
61
+ }
62
+ captureNow() {
63
+ const buffer = this.term.buffer.active;
64
+ const lines = [];
65
+ for (let i = 0; i < buffer.length; i++) {
66
+ lines.push(buffer.getLine(i)?.translateToString(true) ?? '');
67
+ }
68
+ while (lines.length > 0 && lines.at(-1) === '')
69
+ lines.pop();
70
+ this.capture = { text: lines.join('\n'), capturedAt: Date.now() };
71
+ }
72
+ }
@@ -0,0 +1,54 @@
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
+ /**
10
+ * Parse a `harness <name> [as <label>] [-w|--workspace] [--offline]` command, validating the
11
+ * harness name against the known set. `as <label>` gives the new tab a custom label instead of
12
+ * the harness name (still de-duplicated against existing tab labels). `--offline` adds a
13
+ * network-deny rule to the tab's sandbox profile (only meaningful alongside `-w`/`--workspace`).
14
+ * `harness capture <name>` is the other form: `<name>` targets an existing harness tab by label
15
+ * (`capture` can never collide with a harness name — it is not a HARNESS_COMMANDS key).
16
+ */
17
+ export function parseHarnessCommand(input) {
18
+ const rest = input.replace(/^harness\b\s*/i, '').trim();
19
+ if (!rest)
20
+ return { error: `Usage: harness <${HARNESS_NAMES.join('|')}> [as <label>] [-w].` };
21
+ const tokens = rest.split(/\s+/);
22
+ if (tokens[0].toLowerCase() === 'capture') {
23
+ const label = tokens[1];
24
+ if (!label)
25
+ return { error: 'Usage: harness capture <name>.' };
26
+ return { capture: true, label };
27
+ }
28
+ const name = tokens[0].toLowerCase();
29
+ if (HARNESS_COMMANDS[name] === undefined) {
30
+ return { error: `Unknown harness "${name}". Choose from: ${HARNESS_NAMES.join(', ')}.` };
31
+ }
32
+ const rest_ = tokens.slice(1);
33
+ const workspace = rest_.some((t) => t === '-w' || t === '--workspace');
34
+ const offline = rest_.some((t) => t.toLowerCase() === '--offline');
35
+ const asIndex = rest_.findIndex((t) => t.toLowerCase() === 'as');
36
+ if (asIndex === -1)
37
+ return { name, workspace, offline };
38
+ const label = rest_[asIndex + 1];
39
+ if (!label)
40
+ return { error: `Usage: harness <${HARNESS_NAMES.join('|')}> as <label>.` };
41
+ return { name, workspace, offline, label };
42
+ }
43
+ // Single-quote a value for embedding in a `shell -lc '<command>'` string, escaping any embedded
44
+ // single quotes (`'` → `'\''`).
45
+ function shellQuote(value) {
46
+ return `'${value.replaceAll("'", String.raw `'\''`)}'`;
47
+ }
48
+ // Build the shell command string that launches a harness binary, optionally with a model flag.
49
+ // `buildHarnessCommand('opencode', 'opencode-go/deepseek-v4-pro')` →
50
+ // `opencode --model 'opencode-go/deepseek-v4-pro'`.
51
+ export function buildHarnessCommand(name, model) {
52
+ const program = HARNESS_COMMANDS[name];
53
+ return model ? `${program} --model ${shellQuote(model)}` : program;
54
+ }
package/dist/index.js ADDED
@@ -0,0 +1,163 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { WebSocketServer, WebSocket } from 'ws';
5
+ import { Controller } from './controller.js';
6
+ import { makeToken, originAllowed, tokenFromReq as tokenFromRequest, tokenMatches } from './security.js';
7
+ import { handle } from './message-handler.js';
8
+ import { buildStateEvent } from './state-event.js';
9
+ // Applied to every HTTP response: defence-in-depth for the XSS path and token leak.
10
+ const SECURITY_HEADERS = {
11
+ 'Referrer-Policy': 'no-referrer',
12
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; frame-src https: http:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
13
+ };
14
+ const MIME = {
15
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
16
+ '.json': 'application/json', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
17
+ '.woff2': 'font/woff2', '.map': 'application/json',
18
+ // Image types served via the `/open/<id>` route (opened files).
19
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
20
+ '.webp': 'image/webp', '.bmp': 'image/bmp', '.avif': 'image/avif',
21
+ // Markdown files served via the `/open/<id>` route.
22
+ '.md': 'text/markdown; charset=utf-8', '.markdown': 'text/markdown; charset=utf-8',
23
+ // Text types with their own registered MIME, served via the `/open/<id>` route (editor opener).
24
+ '.mjs': 'text/javascript', '.cjs': 'text/javascript', '.xml': 'application/xml',
25
+ '.csv': 'text/csv; charset=utf-8',
26
+ // The rest of the editor opener's plain-text extensions all serve as text/plain.
27
+ ...Object.fromEntries([
28
+ '.txt', '.text', '.log', '.yaml', '.yml', '.toml', '.ini', '.conf', '.cfg', '.env',
29
+ '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.rs', '.c', '.h', '.cpp', '.hpp', '.java',
30
+ '.sh', '.bash', '.zsh', '.sql',
31
+ ].map((extension) => [extension, 'text/plain; charset=utf-8'])),
32
+ };
33
+ export async function startServer(options) {
34
+ const token = options.token ?? makeToken();
35
+ const host = options.host ?? '127.0.0.1';
36
+ const clients = new Set();
37
+ let closed = false;
38
+ let exitTimer;
39
+ const broadcast = (event) => {
40
+ const s = JSON.stringify(event);
41
+ for (const c of clients)
42
+ if (c.readyState === WebSocket.OPEN)
43
+ c.send(s);
44
+ };
45
+ // Reassigned below once `close` exists, so the `quit` command can shut the server down cleanly.
46
+ let requestExit = () => process.exit(0);
47
+ const controller = new Controller({
48
+ emitState: () => broadcast(buildStateEvent(controller)),
49
+ sendPty: (id, data) => broadcast({ t: 'pty', id, data }),
50
+ sendPtyExit: (id, exitCode) => broadcast({ t: 'pty-exit', id, exitCode }),
51
+ exit: () => requestExit(),
52
+ }, options.projectDir);
53
+ if (options.relaunch)
54
+ controller.rehydrate();
55
+ const serveStatic = async (request, res) => {
56
+ if (!originAllowed(request)) {
57
+ res.writeHead(403).end('forbidden');
58
+ return;
59
+ }
60
+ const urlPath = new URL(request.url ?? '/', 'http://localhost').pathname;
61
+ // A file explicitly opened in the app (`open <file>`). Guarded by the session token and served
62
+ // only from the controller's allow-list — an arbitrary local path is never reachable.
63
+ if (urlPath.startsWith('/open/')) {
64
+ if (!tokenMatches(token, tokenFromRequest(request))) {
65
+ res.writeHead(403).end('forbidden');
66
+ return;
67
+ }
68
+ const id = decodeURIComponent(urlPath.slice('/open/'.length));
69
+ const filePath = controller.openFilePath(id);
70
+ if (!filePath) {
71
+ res.writeHead(404).end('not found');
72
+ return;
73
+ }
74
+ let bytes;
75
+ try {
76
+ bytes = await readFile(filePath);
77
+ }
78
+ catch {
79
+ bytes = Buffer.alloc(0);
80
+ }
81
+ res.writeHead(200, { ...SECURITY_HEADERS, 'content-type': MIME[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream' });
82
+ res.end(bytes);
83
+ return;
84
+ }
85
+ // Resolve within webDir; fall back to index.html for SPA routes / unknown assets.
86
+ const rel = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '').replace(/^\/+/, '');
87
+ let file = path.join(options.webDir, rel || 'index.html');
88
+ if (!file.startsWith(options.webDir))
89
+ file = path.join(options.webDir, 'index.html');
90
+ let body;
91
+ try {
92
+ body = await readFile(file);
93
+ }
94
+ catch {
95
+ try {
96
+ body = await readFile(path.join(options.webDir, 'index.html'));
97
+ file = 'index.html';
98
+ }
99
+ catch {
100
+ res.writeHead(404).end('not found');
101
+ return;
102
+ }
103
+ }
104
+ res.writeHead(200, { ...SECURITY_HEADERS, 'content-type': MIME[path.extname(file)] ?? 'application/octet-stream' });
105
+ res.end(body);
106
+ };
107
+ const http = createServer((request, res) => { void serveStatic(request, res); });
108
+ const wss = new WebSocketServer({ noServer: true });
109
+ http.on('upgrade', (request, socket, head) => {
110
+ if (!originAllowed(request) || !tokenMatches(token, tokenFromRequest(request))) {
111
+ socket.destroy();
112
+ return;
113
+ }
114
+ wss.handleUpgrade(request, socket, head, (ws) => wss.emit('connection', ws, request));
115
+ });
116
+ wss.on('connection', (ws) => {
117
+ clients.add(ws);
118
+ ws.on('message', (raw) => {
119
+ let message;
120
+ try {
121
+ message = JSON.parse(raw.toString());
122
+ }
123
+ catch {
124
+ return;
125
+ }
126
+ try {
127
+ handle(controller, message, (event) => ws.send(JSON.stringify(event)));
128
+ }
129
+ catch (error) {
130
+ ws.send(JSON.stringify({ t: 'rpc-reply', id: message.id, error: error instanceof Error ? error.message : String(error) }));
131
+ }
132
+ });
133
+ ws.on('close', () => {
134
+ clients.delete(ws);
135
+ if (clients.size === 0 && !closed) {
136
+ broadcast({ t: 'bye' });
137
+ exitTimer = setTimeout(() => { void close().then(() => process.exit(0)); }, 100);
138
+ }
139
+ });
140
+ });
141
+ const port = await new Promise((resolve, reject) => {
142
+ http.on('error', reject);
143
+ http.listen(options.port ?? 0, host, () => {
144
+ const addr = http.address();
145
+ resolve(typeof addr === 'object' && addr ? addr.port : 0);
146
+ });
147
+ });
148
+ const close = () => new Promise((resolve) => {
149
+ closed = true;
150
+ if (exitTimer)
151
+ clearTimeout(exitTimer);
152
+ controller.shutdown();
153
+ for (const c of clients)
154
+ c.close();
155
+ wss.close(() => http.close(() => resolve()));
156
+ });
157
+ requestExit = () => {
158
+ // Ask connected windows to close themselves, then stop the server and process.
159
+ broadcast({ t: 'bye' });
160
+ setTimeout(() => { void close().then(() => process.exit(0)); }, 100);
161
+ };
162
+ return { url: `http://${host}:${port}/?token=${token}`, port, token, close, shutdown: () => requestExit() };
163
+ }