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,100 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { openerForExtension } from './openers/index.js';
5
+ import { didOsOpen } from './openers/os-open.js';
6
+ import { openInEditor } from './openers/editor.js';
7
+ import { parseOpen, isGlobPattern } from './commands/open.js';
8
+ import { expandUserPath } from './paths.js';
9
+ import { webOpener } from './openers/page.js';
10
+ import { SHELL_NAME } from './shell-manager.js';
11
+ import { TabManager } from './tab/manager.js';
12
+ export class OpenFileManager {
13
+ managers;
14
+ constructor(managers) {
15
+ this.managers = managers;
16
+ }
17
+ run(command, label) {
18
+ const parsed = parseOpen(command);
19
+ if ('error' in parsed) {
20
+ this.managers.tab.append(label, { input: command, output: parsed.error });
21
+ return;
22
+ }
23
+ const cwd = this.managers.tab.cwdOf(label) ?? process.cwd();
24
+ const context = this.buildContext(command, label);
25
+ const target = expandUserPath(parsed.target, { root: this.managers.tab.launchDir });
26
+ if (parsed.web) {
27
+ void (parsed.external ? webOpener.external(target, context) : webOpener.inline(target, context));
28
+ return;
29
+ }
30
+ if (isGlobPattern(target)) {
31
+ const matches = this.expandGlob(target, cwd);
32
+ if (matches.length === 0) {
33
+ this.managers.tab.append(label, { input: command, output: `open: ${target}: no matching files` });
34
+ return;
35
+ }
36
+ const files = matches.slice(0, TabManager.OPEN_MAX_FILES);
37
+ if (matches.length > files.length) {
38
+ this.managers.tab.append(label, { input: command, output: `Opening the first ${files.length} of ${matches.length} matching files.` });
39
+ }
40
+ for (const file of files)
41
+ this.openOne(command, label, file, parsed.external, context);
42
+ return;
43
+ }
44
+ const file = path.isAbsolute(target) ? target : path.resolve(cwd, target);
45
+ this.openOne(command, label, file, parsed.external, context);
46
+ }
47
+ // The `edit <file>` command: resolve the target like `open` does, but bypass the opener
48
+ // registry and hand the file straight to the editor — this is how markdown and extensionless
49
+ // files (Makefile, .gitignore) get edited.
50
+ edit(command, target, label, line) {
51
+ const cwd = this.managers.tab.cwdOf(label) ?? process.cwd();
52
+ const expanded = expandUserPath(target, { root: this.managers.tab.launchDir });
53
+ const file = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
54
+ openInEditor(file, this.buildContext(command, label), line);
55
+ }
56
+ buildContext(command, label) {
57
+ return {
58
+ note: (text) => this.managers.tab.append(label, { input: command, output: text }),
59
+ openImageTab: (image) => this.managers.tab.openImageTab(image),
60
+ openMarkdownTab: (view) => this.managers.tab.openMarkdownTab(view),
61
+ openEditorTab: (view) => this.managers.tab.openEditorTab(view),
62
+ openPageTab: (view) => this.managers.tab.openPageTab(view),
63
+ registerFile: (absPath) => this.managers.tab.registerFile(absPath),
64
+ openExternally: (absPath) => didOsOpen(absPath),
65
+ };
66
+ }
67
+ openOne(command, label, file, external, context) {
68
+ if (!existsSync(file)) {
69
+ this.managers.tab.append(label, { input: command, output: `open: ${file}: no such file` });
70
+ return;
71
+ }
72
+ const opener = openerForExtension(path.extname(file));
73
+ if (!opener) {
74
+ this.managers.tab.append(label, { input: command, output: `No opener for "${path.extname(file) || '(none)'}" files.` });
75
+ return;
76
+ }
77
+ void (external ? opener.external(file, context) : opener.inline(file, context));
78
+ }
79
+ expandGlob(pattern, cwd) {
80
+ let stdout;
81
+ try {
82
+ const res = spawnSync(SHELL_NAME, ['-c', String.raw `for f in ${pattern}; do printf '%s\n' "$f"; done`], {
83
+ cwd, encoding: 'utf8', timeout: 5000,
84
+ });
85
+ stdout = res.stdout ?? '';
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ const files = stdout.split('\n').map((s) => s.trim()).filter(Boolean)
91
+ .map((p) => (path.isAbsolute(p) ? p : path.resolve(cwd, p)))
92
+ .filter((p) => { try {
93
+ return statSync(p).isFile();
94
+ }
95
+ catch {
96
+ return false;
97
+ } });
98
+ return [...new Set(files)].toSorted((a, b) => a.localeCompare(b));
99
+ }
100
+ }
@@ -0,0 +1,41 @@
1
+ import { statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { humanSize } from './size.js';
4
+ import { openInDefaultViewer } from './external-viewer.js';
5
+ // Refuse to open files above this size: the editor holds the whole buffer in memory and a
6
+ // multi-megabyte file is almost certainly not something to hand-edit in-app.
7
+ export const EDITOR_MAX_BYTES = 2 * 1024 * 1024;
8
+ // Common plain-text extensions the editor claims for `open`. Markdown is deliberately absent —
9
+ // `open foo.md` keeps the rendered view; `edit foo.md` forces the editor.
10
+ const EXTENSIONS = [
11
+ '.txt', '.text', '.log',
12
+ '.json', '.yaml', '.yml', '.toml', '.ini', '.conf', '.cfg', '.env',
13
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
14
+ '.py', '.rb', '.go', '.rs', '.c', '.h', '.cpp', '.hpp', '.java',
15
+ '.sh', '.bash', '.zsh',
16
+ '.css', '.html', '.xml', '.sql', '.csv',
17
+ ];
18
+ // Shared inline handler: also the entry point for the `edit` command, which bypasses the opener
19
+ // registry so any file (markdown, extensionless) can be edited.
20
+ export function openInEditor(file, context, line) {
21
+ const name = path.basename(file);
22
+ let bytes;
23
+ try {
24
+ bytes = statSync(file).size;
25
+ }
26
+ catch {
27
+ bytes = undefined;
28
+ }
29
+ if (bytes !== undefined && bytes > EDITOR_MAX_BYTES) {
30
+ context.note(`edit: ${name} is ${humanSize(bytes)} — too large to edit in-app (limit ${humanSize(EDITOR_MAX_BYTES)}).`);
31
+ return;
32
+ }
33
+ const size = bytes === undefined ? 'unknown' : humanSize(bytes);
34
+ context.openEditorTab({ name, path: file, size, url: context.registerFile(file), line });
35
+ }
36
+ export const opener = {
37
+ name: 'editor',
38
+ extensions: EXTENSIONS,
39
+ external: openInDefaultViewer,
40
+ inline: (file, context) => { openInEditor(file, context); },
41
+ };
@@ -0,0 +1,11 @@
1
+ import path from 'node:path';
2
+ // Hand a file to the OS's default viewer (via `context.openExternally`), noting success or the
3
+ // fallback path in the originating tab's transcript. Shared by openers with no specialized
4
+ // external-viewer messaging of their own.
5
+ export function openInDefaultViewer(file, context) {
6
+ const name = path.basename(file);
7
+ if (context.openExternally(file))
8
+ context.note(`Opening ${name} in your default viewer…`);
9
+ else
10
+ context.note(`No viewer available. The file is at ${file}`);
11
+ }
@@ -0,0 +1,27 @@
1
+ import { statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { humanSize } from './size.js';
4
+ // The first opener: handles common raster and vector image types. `external` hands the image to the
5
+ // OS image viewer; `inline` mounts an image tab showing the file's metadata and the image itself.
6
+ export const opener = {
7
+ name: 'image',
8
+ extensions: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif', '.ico'],
9
+ external: (file, context) => {
10
+ const name = path.basename(file);
11
+ if (context.openExternally(file))
12
+ context.note(`Opening ${name} in your image viewer…`);
13
+ else
14
+ context.note(`No image viewer available. The file is at ${file}`);
15
+ },
16
+ inline: (file, context) => {
17
+ const name = path.basename(file);
18
+ let size;
19
+ try {
20
+ size = humanSize(statSync(file).size);
21
+ }
22
+ catch {
23
+ size = 'unknown';
24
+ }
25
+ context.openImageTab({ name, path: file, size, url: context.registerFile(file) });
26
+ },
27
+ };
@@ -0,0 +1,16 @@
1
+ import { opener as image } from './image.js';
2
+ import { opener as markdown } from './markdown.js';
3
+ import { opener as editor } from './editor.js';
4
+ // The opener registry. The `open` dispatcher walks this list in order and picks the first opener
5
+ // whose `extensions` include the target file's extension. Supporting a new file type is additive:
6
+ // add one opener module and one entry here — the dispatcher is never touched.
7
+ export const openers = [
8
+ image,
9
+ markdown,
10
+ editor,
11
+ ];
12
+ // Find the opener registered for a file extension (lowercased, dot-prefixed), or undefined.
13
+ export function openerForExtension(extension) {
14
+ const lowerExtension = extension.toLowerCase();
15
+ return openers.find((o) => o.extensions.includes(lowerExtension));
16
+ }
@@ -0,0 +1,20 @@
1
+ import { statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { humanSize } from './size.js';
4
+ import { openInDefaultViewer } from './external-viewer.js';
5
+ export const opener = {
6
+ name: 'markdown',
7
+ extensions: ['.md', '.markdown'],
8
+ external: openInDefaultViewer,
9
+ inline: (file, context) => {
10
+ const name = path.basename(file);
11
+ let size;
12
+ try {
13
+ size = humanSize(statSync(file).size);
14
+ }
15
+ catch {
16
+ size = 'unknown';
17
+ }
18
+ context.openMarkdownTab({ name, path: file, size, url: context.registerFile(file) });
19
+ },
20
+ };
@@ -0,0 +1,24 @@
1
+ import { spawn } from 'node:child_process';
2
+ // Hand a file to the operating system's default application, launched detached so it never blocks
3
+ // the app's event loop (the same pattern as `browser shot` in src/browser.ts). Spawn errors are
4
+ // swallowed — a missing opener must not crash the app. Returns false on a platform with no known
5
+ // opener so the caller can fall back to reporting the path.
6
+ export function didOsOpen(path) {
7
+ const command = process.platform === 'darwin' ? 'open'
8
+ : process.platform === 'win32' ? 'start'
9
+ : process.platform === 'linux' ? 'xdg-open'
10
+ : undefined;
11
+ if (!command)
12
+ return false;
13
+ // `start` is a cmd.exe builtin; the empty title arg avoids it consuming a quoted path as the title.
14
+ const arguments_ = command === 'start' ? ['', path] : [path];
15
+ try {
16
+ const child = spawn(command, arguments_, { stdio: 'ignore', detached: true, shell: command === 'start' });
17
+ child.on('error', () => { });
18
+ child.unref();
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
@@ -0,0 +1,51 @@
1
+ export function normalizeWebUrl(target) {
2
+ let raw = target.trim();
3
+ if (!raw)
4
+ return { error: 'empty URL' };
5
+ // Reject non-http/https schemes explicitly (e.g. javascript:, file:, ftp:).
6
+ if (/^[a-z][a-z\d+\-.]*:/i.test(raw) && !/^https?:/i.test(raw)) {
7
+ return { error: `unsupported scheme in "${raw}"` };
8
+ }
9
+ // Bare target (no scheme) — default to https.
10
+ if (!/^https?:/i.test(raw))
11
+ raw = `https://${raw}`;
12
+ try {
13
+ const parsed = new URL(raw);
14
+ return { url: parsed.href };
15
+ }
16
+ catch {
17
+ return { error: `invalid URL "${target}"` };
18
+ }
19
+ }
20
+ // Strip leading "www." and reduce to the registrable domain (last 2 labels, or 3 when the
21
+ // second-level label is a well-known short SLD: co, com, org, net, gov, ac, edu).
22
+ export function rootDomain(hostname) {
23
+ const clean = hostname.replace(/^www\./i, '');
24
+ const labels = clean.split('.');
25
+ if (labels.length <= 2)
26
+ return clean;
27
+ const SHORT_SLDS = new Set(['co', 'com', 'org', 'net', 'gov', 'ac', 'edu']);
28
+ const second = labels.at(-2) ?? '';
29
+ const keep = SHORT_SLDS.has(second) ? 3 : 2;
30
+ return labels.slice(-keep).join('.');
31
+ }
32
+ export const webOpener = {
33
+ name: 'page',
34
+ inline: (target, context) => {
35
+ const n = normalizeWebUrl(target);
36
+ if ('error' in n) {
37
+ context.note(`open: invalid URL "${target}"`);
38
+ return;
39
+ }
40
+ context.openPageTab({ url: n.url, domain: rootDomain(new URL(n.url).hostname) });
41
+ },
42
+ external: (target, context) => {
43
+ const n = normalizeWebUrl(target);
44
+ if ('error' in n) {
45
+ context.note(`open: invalid URL "${target}"`);
46
+ return;
47
+ }
48
+ const domain = rootDomain(new URL(n.url).hostname);
49
+ context.note(context.openExternally(n.url) ? `Opening ${domain} in your browser…` : `No browser available. The address is ${n.url}`);
50
+ },
51
+ };
@@ -0,0 +1,13 @@
1
+ // Render a byte count as a compact human-readable size (e.g. "1.4 MB", "812 B").
2
+ export function humanSize(bytes) {
3
+ if (bytes < 1024)
4
+ return `${bytes} B`;
5
+ const units = ['KB', 'MB', 'GB', 'TB'];
6
+ let value = bytes / 1024;
7
+ let index = 0;
8
+ while (value >= 1024 && index < units.length - 1) {
9
+ value /= 1024;
10
+ index++;
11
+ }
12
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[index]}`;
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ // Cache a page tab's currently visible text as transient snapshot state on the tab, relayed by the
2
+ // bundled extension's content script. `url` is the tab's `page.url`, matched the same way
3
+ // `syncEditorBuffer` resolves its tab — this never touches the filesystem or is sent to any client,
4
+ // it only writes the in-memory `pageSnapshot`. An unresolvable `url` (e.g. a tab closed mid-relay)
5
+ // is a no-op, never an error.
6
+ export function syncPageSnapshot(managers, url, text) {
7
+ const tab = managers.tab.tabs.find((t) => t.page?.url === url);
8
+ if (tab)
9
+ tab.pageSnapshot = { text, capturedAt: Date.now() };
10
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,45 @@
1
+ import { homedir } from 'node:os';
2
+ import path from 'node:path';
3
+ // Expand a user-authored path by replacing `~` with the home directory and `$root` with the
4
+ // project root. Only the path start is expanded — `~` and `$root` appearing mid-path are left
5
+ // as-is. This is the inverse of `abbreviatePath()`.
6
+ export function expandUserPath(input, context) {
7
+ const { root } = context;
8
+ if (input === '$root' || input === '$root/')
9
+ return root;
10
+ if (input.startsWith('$root/'))
11
+ return root + input.slice(5);
12
+ const home = context.home ?? homedir();
13
+ if (input === '~')
14
+ return home;
15
+ if (input.startsWith('~/'))
16
+ return home + input.slice(1);
17
+ return input;
18
+ }
19
+ // Abbreviate an absolute path for display in the transcript. The launch (root) directory reads as
20
+ // `$root`, and the application's hidden state directory inside it (`.janissary`) folds into the root
21
+ // too — its `.janissary` segment is elided so its contents read directly under `$root` (e.g. a
22
+ // workspace clone at `<root>/.janissary/workspace/<name>` shows as `$root/workspace/<name>`). A path
23
+ // elsewhere under home reads as `~`. The longest matching prefix wins. Returns the path unchanged
24
+ // when none applies. Display-only — callers keep the real path.
25
+ export function abbreviatePath(p, context) {
26
+ const { root } = context;
27
+ const state = path.join(root, '.janissary');
28
+ // State directory inside the root (longest, checked first).
29
+ if (p === state)
30
+ return '$root/';
31
+ if (p.startsWith(state + path.sep))
32
+ return '$root' + p.slice(state.length);
33
+ // The root directory itself and anything under it.
34
+ if (p === root)
35
+ return '$root/';
36
+ if (p.startsWith(root + path.sep))
37
+ return '$root' + p.slice(root.length);
38
+ // Elsewhere under home.
39
+ const home = context.home ?? homedir();
40
+ if (p === home)
41
+ return '~';
42
+ if (p.startsWith(home + path.sep))
43
+ return '~' + p.slice(home.length);
44
+ return p;
45
+ }
@@ -0,0 +1,49 @@
1
+ // The only tools a persona may opt into (see `[//]: # tools:` config line). Every other tool stays
2
+ // denied; a persona that declares none is fully tool-less (today's behavior).
3
+ export const SUPPORTED_PERSONA_TOOLS = ['web_search', 'web_fetch'];
4
+ const COMMENT_PREFIX = '[//]: # ';
5
+ // Whether a line is the optional `[//]: # tools: …` config comment (case-insensitive label).
6
+ export function isPersonaToolsLine(line) {
7
+ if (!line.startsWith(COMMENT_PREFIX))
8
+ return false;
9
+ return line.slice(COMMENT_PREFIX.length).trimStart().toLowerCase().startsWith('tools:');
10
+ }
11
+ // Parse a `[//]: # tools: a, b` line into a de-duplicated, lowercased tool list. Entries are
12
+ // validated against SUPPORTED_PERSONA_TOOLS; an unknown tool throws (fail loud, like a bad
13
+ // directive), so a persona never looks enabled when it isn't. An empty list is allowed.
14
+ export function parsePersonaTools(name, line) {
15
+ const content = line.slice(COMMENT_PREFIX.length).trimStart();
16
+ const afterLabel = content.slice(content.indexOf(':') + 1);
17
+ const entries = afterLabel.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
18
+ const result = [];
19
+ for (const entry of entries) {
20
+ if (!SUPPORTED_PERSONA_TOOLS.includes(entry)) {
21
+ throw new Error(`Persona "${name}" requests unknown tool "${entry}" (supported: ${SUPPORTED_PERSONA_TOOLS.join(', ')}).`);
22
+ }
23
+ if (!result.includes(entry))
24
+ result.push(entry);
25
+ }
26
+ return result;
27
+ }
28
+ export function parseDirective(name, line) {
29
+ const prefix = '[//]: # ';
30
+ if (!line.startsWith(prefix)) {
31
+ throw new Error(`Persona "${name}" has no harness directive. First line must be: [//]: # <harness>:<model>:<variant>.`);
32
+ }
33
+ const spec = line.slice(prefix.length).trim();
34
+ const first = spec.indexOf(':');
35
+ const last = spec.lastIndexOf(':');
36
+ if (first === -1 || first === last) {
37
+ throw new Error(`Persona "${name}" has a malformed harness directive. Expected: [//]: # <harness>:<model>:<variant>.`);
38
+ }
39
+ const harness = spec.slice(0, first);
40
+ if (harness !== 'opencode' && harness !== 'claude') {
41
+ throw new Error(`Unknown harness "${harness}" in persona "${name}" (expected opencode or claude).`);
42
+ }
43
+ const model = spec.slice(first + 1, last).trim();
44
+ const variant = spec.slice(last + 1).trim();
45
+ if (!model || !variant) {
46
+ throw new Error(`Persona "${name}" has a malformed harness directive. Expected: [//]: # <harness>:<model>:<variant>.`);
47
+ }
48
+ return { harness, model, variant };
49
+ }
@@ -0,0 +1,44 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ // AI monitor personas: markdown files in `ai/personas/`. The filename (without `.md`)
4
+ // is the persona name used on the `monitor` command line. The first line is a required
5
+ // harness directive; the rest of the file is the monitoring session's startup prompt.
6
+ import { parseDirective, isPersonaToolsLine, parsePersonaTools } from './persona-parsing.js';
7
+ export { parseDirective } from './persona-parsing.js';
8
+ const PERSONAS_DIR = path.join('ai', 'personas');
9
+ function personasDir(root) {
10
+ return path.join(root, PERSONAS_DIR);
11
+ }
12
+ // Read and parse `ai/personas/<name>.md` under `root` (the app's working directory).
13
+ // Throws on a missing file or a missing/malformed/unknown-harness directive.
14
+ export function loadPersona(name, root = process.cwd()) {
15
+ const file = path.join(personasDir(root), `${name}.md`);
16
+ let raw;
17
+ try {
18
+ raw = readFileSync(file, 'utf8');
19
+ }
20
+ catch {
21
+ throw new Error(`No persona "${name}" (looked in ${path.join(PERSONAS_DIR, `${name}.md`)}).`);
22
+ }
23
+ const lines = raw.split('\n');
24
+ const harness = parseDirective(name, (lines[0] ?? '').trim());
25
+ // An optional second `[//]: #` comment line lists the persona's tools; anything else (a blank
26
+ // line, an ordinary comment, or prose) starts the body, so existing personas are unaffected.
27
+ const secondLine = (lines[1] ?? '').trim();
28
+ const hasTools = isPersonaToolsLine(secondLine);
29
+ const tools = hasTools ? parsePersonaTools(name, secondLine) : [];
30
+ const body = lines.slice(hasTools ? 2 : 1).join('\n').trim();
31
+ return { name, harness, body, tools };
32
+ }
33
+ // Persona names available for the `monitor` command (used by completion and errors).
34
+ export function listPersonas(root = process.cwd()) {
35
+ try {
36
+ return readdirSync(personasDir(root))
37
+ .filter((f) => f.endsWith('.md'))
38
+ .map((f) => f.slice(0, -3))
39
+ .toSorted((a, b) => a.localeCompare(b));
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
@@ -0,0 +1,154 @@
1
+ import { distinctColor, makeTab } from '../tab/index.js';
2
+ import { HARNESS_COMMANDS } from '../harness/index.js';
3
+ import { isKnownModel } from '../harness/models.js';
4
+ import { parseScheduleCommand } from '../schedule/index.js';
5
+ import { startProfileMonitors } from './monitors.js';
6
+ import { openProfileFiles } from './files.js';
7
+ import { openProfileNotifications } from './notifications.js';
8
+ function isHarnessEntry(e) {
9
+ return 'harness' in e;
10
+ }
11
+ function labelOf(e) {
12
+ return isHarnessEntry(e) ? e.label : e.name;
13
+ }
14
+ // Authored `schedule` strings (schedule-command grammar minus `in <tab>`) plus `run` one-shots,
15
+ // parsed into the ScheduleEntry[] the harness tab's schedule is set to. A string that errors or
16
+ // carries an `in <tab>` clause is reported and skipped; a duplicate name keeps the first.
17
+ function buildHarnessSchedule(entry, report) {
18
+ const now = new Date();
19
+ const entries = [];
20
+ const ids = new Set();
21
+ const scheduleLines = entry.schedule ?? [];
22
+ for (const raw of scheduleLines) {
23
+ const parsed = parseScheduleCommand(raw, now);
24
+ if ('error' in parsed) {
25
+ report(`Schedule "${raw}" for "${entry.label}": ${parsed.error}`);
26
+ continue;
27
+ }
28
+ if (parsed.action !== 'add') {
29
+ report(`Schedule "${raw}" for "${entry.label}" is not a new schedule.`);
30
+ continue;
31
+ }
32
+ if (parsed.target !== undefined) {
33
+ report(`Schedule "${raw}" for "${entry.label}" cannot target another tab.`);
34
+ continue;
35
+ }
36
+ if (ids.has(parsed.name)) {
37
+ report(`Duplicate schedule name "${parsed.name}" for "${entry.label}"; kept the first.`);
38
+ continue;
39
+ }
40
+ ids.add(parsed.name);
41
+ entries.push({ ...parsed.entry, id: parsed.name });
42
+ }
43
+ const runLines = entry.run ?? [];
44
+ for (const [i, command] of runLines.entries()) {
45
+ entries.push({ id: `run-${i + 1}`, command, spec: 'once', nextRun: Date.now(), recurring: false });
46
+ }
47
+ return entries;
48
+ }
49
+ function openAgentEntry(state, managers, group, groupColor, dotColor) {
50
+ const log = state.log ?? [];
51
+ const tab = makeTab(state.name, dotColor, managers.tab.tabs.length + 1, state.cmdHistory ?? [], log, state.workspaceDir, group, groupColor);
52
+ tab.toolStepsExpanded = false;
53
+ managers.tab.tabs = [...managers.tab.tabs, tab];
54
+ if (state.cwd)
55
+ managers.tab.setCwd(state.name, state.cwd);
56
+ if (state.context)
57
+ managers.tab.setContext(state.name, state.context);
58
+ if (state.schedule)
59
+ managers.schedule.set(state.name, state.schedule);
60
+ managers.tab.persist(managers.tab.buildAgentState(tab, { schedule: state.schedule }));
61
+ }
62
+ // Validate and open a harness entry. Returns an error to report and skip on, or undefined once
63
+ // the tab (and its schedule) is set up.
64
+ function openHarnessEntry(entry, managers, group, groupColor, issuingCwd, notes) {
65
+ if (HARNESS_COMMANDS[entry.harness] === undefined)
66
+ return `unknown harness "${entry.harness}"`;
67
+ if (entry.model && !isKnownModel(entry.harness, entry.model)) {
68
+ return `Unknown model "${entry.model}" for harness "${entry.harness}" — add it to harness-models.json.`;
69
+ }
70
+ // Mirror `parseHarnessCommand`: -y is claude-only and requires a workspace (auto-approval is
71
+ // only allowed in a sandboxed clone). Report and skip rather than open unsafely.
72
+ if (entry.autoApprove && entry.harness !== 'claude')
73
+ return 'autoApprove (-y) is only supported for the claude harness';
74
+ if (entry.autoApprove && !entry.workspace)
75
+ return 'autoApprove (-y) requires workspace (-w)';
76
+ const withCwd = { ...entry, cwd: entry.cwd ?? issuingCwd };
77
+ const error = managers.harness.openFromProfile(withCwd, entry.label, group, groupColor);
78
+ if (error)
79
+ return error;
80
+ const schedule = buildHarnessSchedule(entry, (message) => { notes.push(message); });
81
+ if (schedule.length > 0)
82
+ managers.schedule.set(entry.label, schedule);
83
+ return undefined;
84
+ }
85
+ // Relaunch semantics: close every open tab matching an entry's label first, then the caller opens
86
+ // all entries fresh, so label collisions between a closing tab and an opening one cannot arise.
87
+ // The issuing tab is never closed; if it's named by an entry, that entry is skipped instead.
88
+ function closeMatchingTabs(entries, managers, issuingLabel, skipped, notes) {
89
+ const toOpen = [];
90
+ for (const entry of entries) {
91
+ const label = labelOf(entry);
92
+ if (label.toLowerCase() === issuingLabel.toLowerCase()) {
93
+ skipped.push(`${label} (cannot relaunch the issuing tab)`);
94
+ continue;
95
+ }
96
+ const index = managers.tab.findIndex(label);
97
+ if (index !== -1) {
98
+ managers.tab.closeTab(index);
99
+ notes.push(`Relaunched "${label}".`);
100
+ }
101
+ toOpen.push(entry);
102
+ }
103
+ return toOpen;
104
+ }
105
+ export function openProfileEntries(entries, managers, name, issuingLabel, out) {
106
+ const authoredGroup = entries
107
+ .map((e) => e.group)
108
+ .find((g) => typeof g === 'number');
109
+ const group = authoredGroup ?? Math.max(0, ...managers.tab.tabs.map((t) => t.group)) + 1;
110
+ const skipped = [];
111
+ const notes = [];
112
+ const toOpen = closeMatchingTabs(entries, managers, issuingLabel, skipped, notes);
113
+ const used = new Set(managers.tab.tabs.map((t) => t.dotColor));
114
+ const opened = [];
115
+ let groupColor;
116
+ const firstNew = managers.tab.tabs.length;
117
+ const issuingCwd = managers.tab.cwdOf(issuingLabel) ?? process.cwd();
118
+ for (const entry of toOpen) {
119
+ const label = labelOf(entry);
120
+ const dotColor = distinctColor(used, entry.dotColor);
121
+ used.add(dotColor);
122
+ groupColor ??= dotColor;
123
+ if (isHarnessEntry(entry)) {
124
+ const error = openHarnessEntry(entry, managers, group, groupColor, issuingCwd, notes);
125
+ if (error) {
126
+ skipped.push(`${label} (${error})`);
127
+ continue;
128
+ }
129
+ }
130
+ else {
131
+ openAgentEntry(entry, managers, group, groupColor, dotColor);
132
+ }
133
+ opened.push(label);
134
+ }
135
+ if (opened.length > 0)
136
+ managers.tab.setActiveTab(firstNew);
137
+ // Profile-level file navigator(s) open next, rooted at the first newly opened tab by default, so
138
+ // their tabs are part of the list by the time monitor targets are resolved below.
139
+ const firstNewLabel = opened.length > 0 ? managers.tab.tabs[firstNew]?.label : undefined;
140
+ openProfileFiles(name, managers, firstNewLabel, notes);
141
+ // Profile-level notifications tab opens next, docked per the profile's `_notifications.json`.
142
+ openProfileNotifications(name, managers, notes);
143
+ // Profile-level monitors start after every entry is open, owned by the issuing tab, so their
144
+ // targets (e.g. `group:1`) can resolve against the now-complete tab list.
145
+ startProfileMonitors(name, managers, issuingLabel, notes);
146
+ const parts = [];
147
+ if (opened.length > 0)
148
+ parts.push(`Launched profile "${name}": ${opened.join(', ')}.`);
149
+ if (notes.length > 0)
150
+ parts.push(notes.join(' '));
151
+ if (skipped.length > 0)
152
+ parts.push(`Skipped: ${skipped.join('; ')}.`);
153
+ out(parts.length > 0 ? parts.join(' ') : `Profile "${name}" has no agents to open.`);
154
+ }
@@ -0,0 +1,17 @@
1
+ import { loadProfileFiles } from '../profiles.js';
2
+ // Open each profile-level file-tree tab (from the profile's `_files.json`) once every entry is
3
+ // open, rooted at `defaultLabel` (the profile's first newly opened tab) unless the entry names its
4
+ // own `in` target. `defaultLabel` is undefined when the profile opened nothing, in which case an
5
+ // entry with no `in` has nothing to root itself at and is skipped with a note.
6
+ export function openProfileFiles(profileName, managers, defaultLabel, notes) {
7
+ for (const entry of loadProfileFiles(profileName)) {
8
+ const label = entry.in ?? defaultLabel;
9
+ if (label === undefined) {
10
+ notes.push('File navigator: no tab to root it at.');
11
+ continue;
12
+ }
13
+ const clauses = [entry.in ? `in ${entry.in}` : '', entry.dock ? `on ${entry.dock}` : ''].filter(Boolean).join(' ');
14
+ managers.fileTree.open(`files ${clauses}`.trim(), label);
15
+ notes.push(`Opened file navigator${entry.dock ? ` (docked ${entry.dock})` : ''}.`);
16
+ }
17
+ }