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,124 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtempSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { chromium } from 'playwright';
6
+ import { randomBrowserProfile, acceptLanguage, DEFAULT_CHROME_MAJOR } from './user-agent.js';
7
+ // Pull the major version out of a Playwright version string like "149.0.7827.55" so the
8
+ // generated UA claims the same Chrome version the engine actually reports via client hints.
9
+ function chromeMajor(version) {
10
+ const major = Number(version.split('.', 1)[0]);
11
+ return Number.isFinite(major) ? major : DEFAULT_CHROME_MAJOR;
12
+ }
13
+ // A headless/headed browser is owned per janissary tab: each tab launches its own
14
+ // Playwright process (so modes can differ between tabs) and holds one or more isolated
15
+ // windows. A "window" is a BrowserContext (its own cookies/storage) plus a single Page —
16
+ // the viewport. The Browser handle stays private to the TabBrowser; callers only ever see
17
+ // the BrowserWindow surface and the TabBrowser lifecycle methods.
18
+ // Cap on `content()` output so a large page does not blow up the prompt fed back to an
19
+ // ACP agent. Truncation is flagged in the returned text.
20
+ const CONTENT_LIMIT = 10_000;
21
+ function makeWindow(id, page) {
22
+ return {
23
+ id,
24
+ goto: async (url) => {
25
+ await page.goto(url, { waitUntil: 'load' });
26
+ const title = await page.title();
27
+ return `${title || '(untitled)'} — ${page.url()}`;
28
+ },
29
+ eval: async (js) => {
30
+ const result = await page.evaluate(js);
31
+ return result === undefined ? 'undefined' : JSON.stringify(result, undefined, 2);
32
+ },
33
+ shot: async () => {
34
+ const temporaryDirectory = mkdtempSync(path.join(tmpdir(), 'janissary-'));
35
+ const screenshotPath = path.join(temporaryDirectory, `${id}-${Date.now()}.png`);
36
+ await page.screenshot({ path: screenshotPath });
37
+ if (process.platform === 'darwin') {
38
+ // Open the screenshot detached so it never blocks the main process.
39
+ const child = spawn('open', ['-a', 'Preview', screenshotPath], { stdio: 'ignore', detached: true });
40
+ child.on('error', () => { });
41
+ child.unref();
42
+ }
43
+ return screenshotPath;
44
+ },
45
+ content: async () => {
46
+ const title = await page.title();
47
+ const body = await page.evaluate(() => document.body?.textContent ?? ''); // eslint-disable-line unicorn/no-optional-chaining-on-undeclared-variable
48
+ const text = `${title}\n\n${body}`.trim();
49
+ return text.length > CONTENT_LIMIT
50
+ ? `${text.slice(0, CONTENT_LIMIT)}\n… (truncated, ${text.length - CONTENT_LIMIT} more chars)`
51
+ : text;
52
+ },
53
+ url: () => page.url(),
54
+ };
55
+ }
56
+ /**
57
+ * Launch a tab's browser. The mode is fixed for the lifetime of the returned TabBrowser's
58
+ * Playwright process; to switch modes, close all windows (which ends the process) and
59
+ * launch again.
60
+ */
61
+ export async function launchTabBrowser(isHeadless) {
62
+ // `channel: 'chromium'` selects Chromium's new headless mode (a real browser run
63
+ // headless) rather than the legacy headless shell, which is far more detectable.
64
+ // `--disable-blink-features=AutomationControlled` drops the automation flag that sets
65
+ // navigator.webdriver and the "Chrome is being controlled by automated test software"
66
+ // banner.
67
+ const browser = await chromium.launch({
68
+ channel: 'chromium',
69
+ headless: isHeadless,
70
+ args: ['--disable-blink-features=AutomationControlled'],
71
+ });
72
+ const major = chromeMajor(browser.version());
73
+ const windows = new Map();
74
+ return {
75
+ mode: isHeadless ? 'headless' : 'headed',
76
+ openWindow: async (id) => {
77
+ // A fresh, internally consistent fingerprint per window so isolated windows don't
78
+ // share an identical signature: UA (version pinned to the real engine), matching
79
+ // client-hint platform header, Accept-Language, timezone, and a common viewport.
80
+ const profile = randomBrowserProfile(major);
81
+ const context = await browser.newContext({
82
+ userAgent: profile.userAgent,
83
+ locale: profile.locale,
84
+ timezoneId: profile.timezoneId,
85
+ viewport: profile.viewport,
86
+ extraHTTPHeaders: {
87
+ 'Accept-Language': acceptLanguage(profile.locale),
88
+ 'Sec-CH-UA-Platform': `"${profile.platform}"`,
89
+ },
90
+ });
91
+ // Ensure navigator.webdriver is never true in any frame of this context.
92
+ await context.addInitScript(() => {
93
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
94
+ });
95
+ const page = await context.newPage();
96
+ const window = makeWindow(id, page);
97
+ windows.set(id, { window, context });
98
+ return window;
99
+ },
100
+ closeWindow: async (id) => {
101
+ const entry = windows.get(id);
102
+ if (!entry)
103
+ return;
104
+ windows.delete(id);
105
+ try {
106
+ await entry.context.close();
107
+ }
108
+ catch {
109
+ /* already gone */
110
+ }
111
+ },
112
+ window: (id) => windows.get(id)?.window,
113
+ windowIds: () => [...windows.keys()],
114
+ close: async () => {
115
+ windows.clear();
116
+ try {
117
+ await browser.close();
118
+ }
119
+ catch {
120
+ /* already gone */
121
+ }
122
+ },
123
+ };
124
+ }
package/dist/buffer.js ADDED
@@ -0,0 +1,42 @@
1
+ import { expandTabs } from './tab/index.js';
2
+ function isEmptyEntry(entry) {
3
+ return !entry.from && !entry.input && !entry.output;
4
+ }
5
+ export function tryCollapseToolSteps(log, index) {
6
+ const entry = log[index];
7
+ if (!entry.acp || entry.from)
8
+ return null;
9
+ let count = 0;
10
+ let index_ = index;
11
+ while (index_ < log.length) {
12
+ const logEntry = log[index_];
13
+ if (isEmptyEntry(logEntry)) {
14
+ index_++;
15
+ continue;
16
+ }
17
+ if (logEntry.acp && !logEntry.from) {
18
+ count++;
19
+ index_++;
20
+ continue;
21
+ }
22
+ break;
23
+ }
24
+ return { count, newIndex: index_ - 1 };
25
+ }
26
+ export function formatMessageContent(entry, parts) {
27
+ const kind = entry.msgKind ?? 'info';
28
+ const lines = [];
29
+ if (kind === 'response') {
30
+ lines.push({ type: 'message', text: '', from: entry.from, fromColor: entry.fromColor, msgKind: 'response' });
31
+ for (const line of parts) {
32
+ lines.push({ type: 'output', text: expandTabs(line), fromColor: entry.fromColor });
33
+ }
34
+ }
35
+ else {
36
+ lines.push({ type: 'message', text: expandTabs(parts[0] ?? ''), from: entry.from, fromColor: entry.fromColor, msgKind: kind });
37
+ for (const extra of parts.slice(1)) {
38
+ lines.push({ type: 'output', text: expandTabs(extra), fromColor: entry.fromColor });
39
+ }
40
+ }
41
+ return lines;
42
+ }
package/dist/bus.js ADDED
@@ -0,0 +1,52 @@
1
+ // Generic typed pub/sub bus. Channels are named keys of the ChannelMap type parameter; within
2
+ // each channel events are discriminated by a `type` string. Listeners are isolated via per-call
3
+ // try/catch so a throwing subscriber never breaks the emit path.
4
+ export class MessageBus {
5
+ listeners = new Map();
6
+ key(channel, type) {
7
+ return `${String(channel)}:${type}`;
8
+ }
9
+ on(channel, types, listener) {
10
+ const typeArr = Array.isArray(types) ? types : [types];
11
+ for (const type of typeArr) {
12
+ const k = this.key(channel, type);
13
+ const set = this.listeners.get(k) ?? new Set();
14
+ set.add(listener);
15
+ this.listeners.set(k, set);
16
+ }
17
+ return {
18
+ unsubscribe: () => {
19
+ for (const type of typeArr) {
20
+ this.listeners.get(this.key(channel, type))?.delete(listener);
21
+ }
22
+ },
23
+ };
24
+ }
25
+ once(channel, types, listener) {
26
+ const ref = {};
27
+ const wrapper = (event) => {
28
+ ref.sub?.unsubscribe();
29
+ listener(event);
30
+ };
31
+ ref.sub = this.on(channel, types, wrapper);
32
+ return ref.sub;
33
+ }
34
+ emit(channel, event) {
35
+ const set = this.listeners.get(this.key(channel, event.type));
36
+ if (!set)
37
+ return;
38
+ const snapshot = [...set];
39
+ for (const fn of snapshot) {
40
+ try {
41
+ fn(event);
42
+ }
43
+ catch {
44
+ // Isolate subscriber errors so a throwing listener cannot break the emit path.
45
+ }
46
+ }
47
+ }
48
+ clear() {
49
+ this.listeners.clear();
50
+ }
51
+ }
52
+ export const messageBus = new MessageBus();
@@ -0,0 +1,48 @@
1
+ import { isInteractive } from '../interactive.js';
2
+ import { commands } from '../commands/index.js';
3
+ import { routeUnknownCommand } from './router.js';
4
+ export class CaptureManager {
5
+ managers;
6
+ constructor(managers) {
7
+ this.managers = managers;
8
+ }
9
+ run(label, text, callback) {
10
+ if (/^shell\b/i.test(text)) {
11
+ const command = text.replace(/^shell\b\s*/i, '');
12
+ if (command && isInteractive(command)) {
13
+ callback(`Cannot run interactive command remotely: ${command}`);
14
+ return;
15
+ }
16
+ this.managers.shell.run(label, command, { onComplete: callback });
17
+ return;
18
+ }
19
+ const trimmed = text.replace(/^\//, '');
20
+ const index = this.managers.tab.findIndex(label);
21
+ if (index === -1) {
22
+ callback('Tab not found');
23
+ return;
24
+ }
25
+ for (const c of commands) {
26
+ if (c.match(trimmed)) {
27
+ this.dispatchMatchedCommand(c, trimmed, label, index, callback);
28
+ return;
29
+ }
30
+ }
31
+ routeUnknownCommand(text, trimmed, label, this.managers, (l, t, cb) => this.run(l, t, cb), callback);
32
+ }
33
+ dispatchMatchedCommand(c, trimmed, label, index, callback) {
34
+ if (c.name === 'acp') {
35
+ this.managers.acp.run(label, trimmed, callback);
36
+ return;
37
+ }
38
+ if (c.name === 'browser') {
39
+ this.managers.browser.runInteractive(trimmed, label, callback);
40
+ return;
41
+ }
42
+ const tab = this.managers.tab.tabs.find((t) => t.label === label);
43
+ const before = tab?.log.length ?? 0;
44
+ this.managers.command.executeCommand(c.name, trimmed, label, index);
45
+ const after = this.managers.tab.tabs.find((t) => t.label === label)?.log.length ?? 0;
46
+ callback(after > before ? this.managers.tab.tabs.find((t) => t.label === label).log[after - 1].output : '');
47
+ }
48
+ }
@@ -0,0 +1,18 @@
1
+ import { getOutput } from '../commands.js';
2
+ import { toPrefixedCommand } from '../recognizers/index.js';
3
+ import { resolveRouteChoice } from '../route-choice.js';
4
+ export function routeUnknownCommand(text, trimmed, label, managers, run, callback) {
5
+ const output = getOutput(trimmed);
6
+ if (output !== null && !output.startsWith('Unknown command:')) {
7
+ managers.tab.append(label, { input: text, output, markdown: trimmed === 'help' });
8
+ callback(output);
9
+ return;
10
+ }
11
+ const openDbs = managers.database.openDbs(label);
12
+ const choice = resolveRouteChoice(trimmed, openDbs);
13
+ if (choice) {
14
+ run(label, toPrefixedCommand(trimmed, choice), callback);
15
+ return;
16
+ }
17
+ callback(output ?? `Unknown command: "${trimmed}".`);
18
+ }
@@ -0,0 +1,48 @@
1
+ import { isInteractive } from './interactive.js';
2
+ import { commands } from './commands/index.js';
3
+ import { routeUnknownCommand } from './capture-router.js';
4
+ export class CaptureManager {
5
+ managers;
6
+ constructor(managers) {
7
+ this.managers = managers;
8
+ }
9
+ run(label, text, callback) {
10
+ if (/^shell\b/i.test(text)) {
11
+ const command = text.replace(/^shell\b\s*/i, '');
12
+ if (command && isInteractive(command)) {
13
+ callback(`Cannot run interactive command remotely: ${command}`);
14
+ return;
15
+ }
16
+ this.managers.shell.run(label, command, { onComplete: callback });
17
+ return;
18
+ }
19
+ const trimmed = text.replace(/^\//, '');
20
+ const index = this.managers.tab.findIndex(label);
21
+ if (index === -1) {
22
+ callback('Tab not found');
23
+ return;
24
+ }
25
+ for (const c of commands) {
26
+ if (c.match(trimmed)) {
27
+ this.dispatchMatchedCommand(c, trimmed, label, index, callback);
28
+ return;
29
+ }
30
+ }
31
+ routeUnknownCommand(text, trimmed, label, this.managers, (l, t, cb) => this.run(l, t, cb), callback);
32
+ }
33
+ dispatchMatchedCommand(c, trimmed, label, index, callback) {
34
+ if (c.name === 'acp') {
35
+ this.managers.acp.run(label, trimmed, callback);
36
+ return;
37
+ }
38
+ if (c.name === 'browser') {
39
+ this.managers.browser.runInteractive(trimmed, label, callback);
40
+ return;
41
+ }
42
+ const tab = this.managers.tab.tabs.find((t) => t.label === label);
43
+ const before = tab?.log.length ?? 0;
44
+ this.managers.command.executeCommand(c.name, trimmed, label, index);
45
+ const after = this.managers.tab.tabs.find((t) => t.label === label)?.log.length ?? 0;
46
+ callback(after > before ? this.managers.tab.tabs.find((t) => t.label === label).log[after - 1].output : '');
47
+ }
48
+ }
@@ -0,0 +1,20 @@
1
+ import { getOutput } from './commands.js';
2
+ import { analyzeCommand, toPrefixedCommand } from './recognizers/index.js';
3
+ export function routeUnknownCommand(text, trimmed, label, managers, run, callback) {
4
+ const output = getOutput(trimmed);
5
+ if (output !== null && !output.startsWith('Unknown command:')) {
6
+ managers.tab.append(label, { input: text, output, markdown: trimmed === 'help' });
7
+ callback(output);
8
+ return;
9
+ }
10
+ const openDbs = managers.database.openDbs(label);
11
+ const decision = analyzeCommand(trimmed, { openDbs });
12
+ if (decision.kind === 'route' && (decision.route !== 'db' || openDbs.length === 1)) {
13
+ const choice = decision.route === 'db'
14
+ ? { label: '', route: 'db', dbName: openDbs[0] }
15
+ : { label: '', route: decision.route };
16
+ run(label, toPrefixedCommand(trimmed, choice), callback);
17
+ return;
18
+ }
19
+ callback(output ?? `Unknown command: "${trimmed}".`);
20
+ }
@@ -0,0 +1,62 @@
1
+ const RESPONSE_TIMEOUT_MS = 20_000;
2
+ // Sends a single Chrome DevTools Protocol command over Chrome's `--remote-debugging-pipe`
3
+ // file descriptors (fd 3 write, fd 4 read) and waits for the matching response. Messages on
4
+ // the pipe are newline-free JSON terminated by a NUL byte, per Chromium's pipe transport.
5
+ function sendCdpCommand(writePipe, readPipe, method, params) {
6
+ return new Promise((resolve, reject) => {
7
+ const id = 1;
8
+ let buffer = '';
9
+ const cleanup = () => {
10
+ clearTimeout(timeout);
11
+ readPipe.off('data', onData);
12
+ };
13
+ const timeout = setTimeout(() => {
14
+ cleanup();
15
+ reject(new Error(`CDP command ${method} timed out after ${RESPONSE_TIMEOUT_MS}ms`));
16
+ }, RESPONSE_TIMEOUT_MS);
17
+ function onData(chunk) {
18
+ buffer += chunk.toString('utf8');
19
+ let sep = buffer.indexOf('\0');
20
+ while (sep !== -1) {
21
+ const raw = buffer.slice(0, sep);
22
+ buffer = buffer.slice(sep + 1);
23
+ sep = buffer.indexOf('\0');
24
+ let message;
25
+ try {
26
+ message = JSON.parse(raw);
27
+ }
28
+ catch {
29
+ continue;
30
+ }
31
+ if (message.id === id) {
32
+ cleanup();
33
+ if (message.error) {
34
+ reject(new Error(message.error.message));
35
+ }
36
+ else {
37
+ resolve(message.result);
38
+ }
39
+ return;
40
+ }
41
+ }
42
+ }
43
+ readPipe.on('data', onData);
44
+ writePipe.write(`${JSON.stringify({ id, method, params })}\0`);
45
+ });
46
+ }
47
+ // Loads the bundled Frame Enabler extension into an already-launched, branded Chrome over the
48
+ // CDP `--remote-debugging-pipe` file descriptors (`Extensions.loadUnpacked`) — the sanctioned
49
+ // replacement for the `--load-extension` launch flag Google removed from branded Chrome 137+.
50
+ // `Extensions.loadUnpacked` is only reachable over the pipe transport, not `--remote-debugging-port`
51
+ // / a WebSocket connection, so `writePipe`/`readPipe` must be Chrome's inherited fd 3/4 streams.
52
+ // Never throws: any failure is reported as a single stderr warning, since page-tab framing is a
53
+ // nice-to-have, not core functionality.
54
+ export async function loadFrameEnablerExtension(writePipe, readPipe, extDir) {
55
+ try {
56
+ await sendCdpCommand(writePipe, readPipe, 'Extensions.loadUnpacked', { path: extDir });
57
+ }
58
+ catch (error) {
59
+ const reason = error instanceof Error ? error.message : String(error);
60
+ process.stderr.write(`warning: Chrome frame-enabler extension failed to load (${reason}) — sites that block iframing may not render in page tabs\n`);
61
+ }
62
+ }
@@ -0,0 +1,80 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readFileSync, existsSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ export class CliUsageError extends Error {
5
+ }
6
+ export function parseCliArgs(argv) {
7
+ let values;
8
+ let positionals;
9
+ try {
10
+ ({ values, positionals } = parseArgs({
11
+ args: argv,
12
+ options: {
13
+ help: { type: 'boolean' },
14
+ version: { type: 'boolean' },
15
+ relaunch: { type: 'boolean' },
16
+ 'no-open': { type: 'boolean' },
17
+ port: { type: 'string' },
18
+ },
19
+ strict: true,
20
+ allowPositionals: true,
21
+ }));
22
+ }
23
+ catch (error) {
24
+ const code = error.code;
25
+ if (typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS')) {
26
+ const raw = error instanceof Error ? error.message : String(error);
27
+ const cleaned = raw.replace(/^TypeError \[[^\]]+]: /, '');
28
+ throw new CliUsageError(cleaned);
29
+ }
30
+ throw error;
31
+ }
32
+ const port = typeof values.port === 'string' ? Number(values.port) : undefined;
33
+ if (port !== undefined && !(Number.isSafeInteger(port) && port >= 1 && port <= 65_535)) {
34
+ throw new CliUsageError(`invalid --port value: ${values.port}`);
35
+ }
36
+ let projectDir;
37
+ if (positionals.length > 0) {
38
+ const raw = positionals[0];
39
+ const resolved = path.resolve(raw);
40
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
41
+ throw new CliUsageError(`invalid project directory: ${raw} is not a directory`);
42
+ }
43
+ projectDir = resolved;
44
+ }
45
+ if (positionals.length > 1) {
46
+ throw new CliUsageError(`unexpected argument: ${positionals[1]}`);
47
+ }
48
+ return {
49
+ help: Boolean(values.help),
50
+ version: Boolean(values.version),
51
+ relaunch: Boolean(values.relaunch),
52
+ noOpen: Boolean(values['no-open']),
53
+ port,
54
+ projectDir,
55
+ };
56
+ }
57
+ export function usageText() {
58
+ return `Usage: janus [options] [<project-dir>]
59
+
60
+ A terminal UI shell with built-in commands and shell execution.
61
+
62
+ Arguments:
63
+ <project-dir> Target directory (default: current directory)
64
+
65
+ Options:
66
+ --port=<n> Port to listen on (default: auto)
67
+ --no-open Start the server without opening the app window
68
+ --relaunch Reattach to existing state instead of clearing it
69
+ --help Show this help
70
+ --version Show version
71
+
72
+ Environment:
73
+ JANUS_DEBUG=1 print stack traces on failure
74
+ `;
75
+ }
76
+ export function appVersion() {
77
+ const packagePath = path.join(import.meta.dirname, '..', 'package.json');
78
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
79
+ return `${pkg.name} ${pkg.version}`;
80
+ }
@@ -0,0 +1,126 @@
1
+ import { resolveCommand } from './resolve.js';
2
+ import { isInteractive } from './interactive.js';
3
+ import { commands } from './commands/index.js';
4
+ import { toPrefixedCommand } from './recognizers/index.js';
5
+ import { messageBus } from './bus.js';
6
+ import { resolveUnknownCommand } from './command-router.js';
7
+ import { recordGlobalHistory } from './global-history.js';
8
+ export class CommandManager {
9
+ managers;
10
+ pendingRoute = null;
11
+ constructor(managers) {
12
+ this.managers = managers;
13
+ this.managers.tab.setOnIdle((label) => this.drainQueue(label));
14
+ }
15
+ routeView() {
16
+ if (!this.pendingRoute)
17
+ return null;
18
+ return { cmd: this.pendingRoute.cmd, choices: this.pendingRoute.choices.map((c) => c.label) };
19
+ }
20
+ chooseRoute(index) {
21
+ const pending = this.pendingRoute;
22
+ this.pendingRoute = null;
23
+ if (pending && index >= 0 && index < pending.choices.length) {
24
+ const index_ = this.managers.tab.findIndex(pending.label);
25
+ if (index_ !== -1)
26
+ this.run(toPrefixedCommand(pending.cmd, pending.choices[index]), pending.label, index_);
27
+ }
28
+ if (pending)
29
+ this.drainQueue(pending.label);
30
+ messageBus.emit('state', { type: 'dirty' });
31
+ }
32
+ dispatch(text) {
33
+ const trimmed = this.managers.tab.recordHistory(this.managers.tab.activeTab, text);
34
+ if (trimmed)
35
+ recordGlobalHistory(trimmed, this.managers.tab.cur().label);
36
+ this.dispatchOrRun(trimmed, this.managers.tab.cur().label, this.managers.tab.activeTab);
37
+ }
38
+ dispatchTo(label, text) {
39
+ const index = this.managers.tab.findIndex(label);
40
+ if (index === -1)
41
+ return;
42
+ const trimmed = this.managers.tab.recordHistory(index, text);
43
+ if (trimmed)
44
+ recordGlobalHistory(trimmed, label);
45
+ this.dispatchOrRun(trimmed, label, index);
46
+ }
47
+ // Gate seam: agent tabs queue while busy (or while idle with entries already waiting, to
48
+ // preserve FIFO) instead of running immediately. Non-agent tabs and empty input bypass the gate.
49
+ dispatchOrRun(trimmed, label, index) {
50
+ if (!trimmed) {
51
+ this.run(trimmed, label, index);
52
+ return;
53
+ }
54
+ const tab = this.managers.tab.tabs[index];
55
+ const isAgentTab = tab !== undefined && (tab.view === undefined || tab.view === 'agent');
56
+ const wasIdle = !this.managers.tab.isBusy(label);
57
+ const alreadyQueued = this.managers.tab.queueFor(label).length > 0;
58
+ if (isAgentTab && (!wasIdle || alreadyQueued)) {
59
+ this.managers.tab.enqueue(label, trimmed);
60
+ this.managers.tab.append(label, { input: '', output: `Queued: ${trimmed}` });
61
+ if (wasIdle)
62
+ this.drainQueue(label);
63
+ return;
64
+ }
65
+ this.run(trimmed, label, index);
66
+ }
67
+ // Runs queued commands FIFO until the tab goes busy, its queue empties, or a route chooser
68
+ // becomes pending (resumed by `chooseRoute`). Registered as `TabManager`'s onIdle hook.
69
+ drainQueue(label) {
70
+ for (;;) {
71
+ const index = this.managers.tab.findIndex(label);
72
+ if (index === -1 || this.managers.tab.isBusy(label) || this.pendingRoute)
73
+ return;
74
+ const command = this.managers.tab.dequeue(label);
75
+ if (command === undefined)
76
+ return;
77
+ this.run(command, label, index);
78
+ }
79
+ }
80
+ run(input, label, index) {
81
+ if (/^harness\b/i.test(input)) {
82
+ this.managers.tab.append(label, { input, output: '' });
83
+ const error = this.managers.harness.run(input);
84
+ if (error)
85
+ this.managers.tab.append(label, { input: '', output: error });
86
+ return;
87
+ }
88
+ if (/^ssh\b/i.test(input)) {
89
+ this.managers.tab.append(label, { input, output: '' });
90
+ const error = this.managers.ssh.run(input);
91
+ if (error)
92
+ this.managers.tab.append(label, { input: '', output: error });
93
+ return;
94
+ }
95
+ const res = resolveCommand(input);
96
+ switch (res.kind) {
97
+ case 'empty': {
98
+ return;
99
+ }
100
+ case 'shell': {
101
+ if (res.cmd && isInteractive(res.cmd))
102
+ this.managers.pty.openInlinePty(label, res.cmd, res.cmd.split(/\s+/, 1)[0]);
103
+ else
104
+ this.managers.shell.run(label, res.cmd);
105
+ return;
106
+ }
107
+ case 'output': {
108
+ this.managers.tab.append(label, { input, output: res.output, markdown: true });
109
+ return;
110
+ }
111
+ case 'unknown': {
112
+ resolveUnknownCommand(res.cmd, label, this.managers, (input, l, idx) => this.run(input, l, idx), (p) => { this.pendingRoute = p; });
113
+ return;
114
+ }
115
+ case 'app': {
116
+ this.executeCommand(res.name, res.cmd, label, index);
117
+ return;
118
+ }
119
+ }
120
+ }
121
+ executeCommand(name, command, label, index) {
122
+ const cmd = commands.find((c) => c.name === name);
123
+ if (cmd)
124
+ cmd.run(command, { label, index }, this.managers);
125
+ }
126
+ }
@@ -0,0 +1,14 @@
1
+ import { messageBus } from './bus.js';
2
+ import { toPrefixedCommand, routeChoices } from './recognizers/index.js';
3
+ import { resolveRouteChoice } from './route-choice.js';
4
+ export function resolveUnknownCommand(cmd, label, managers, run, setPending) {
5
+ const openDbs = managers.database.openDbs(label);
6
+ const choice = resolveRouteChoice(cmd, openDbs);
7
+ if (choice) {
8
+ run(toPrefixedCommand(cmd, choice), label, managers.tab.findIndex(label));
9
+ }
10
+ else {
11
+ setPending({ label, cmd, choices: routeChoices(openDbs) });
12
+ messageBus.emit('state', { type: 'dirty' });
13
+ }
14
+ }
@@ -0,0 +1,13 @@
1
+ export const command = {
2
+ name: 'acp-reset',
3
+ match: (command_) => /^acp\s+reset\b/i.test(command_),
4
+ run: (_command, tab, managers) => {
5
+ const hadSession = managers.acp.close(tab.label);
6
+ managers.tab.append(tab.label, {
7
+ input: _command,
8
+ output: hadSession
9
+ ? 'ACP session reset — next acp prompt will start fresh.'
10
+ : 'No active ACP session to reset.',
11
+ });
12
+ },
13
+ };
@@ -0,0 +1,5 @@
1
+ export const command = {
2
+ name: 'acp',
3
+ match: (command_) => /^acp\b/i.test(command_),
4
+ run: (command, tab, managers) => { managers.acp.run(tab.label, command); },
5
+ };
@@ -0,0 +1,5 @@
1
+ export const command = {
2
+ name: 'agent',
3
+ match: (command_) => /^agent\b/i.test(command_),
4
+ run: (command, context, managers) => { managers.profile.newAgent(command); },
5
+ };