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,75 @@
1
+ // Autonomous tool loop for an ACP agent: the agent emits a single command, the
2
+ // host runs it, feeds the output back, and repeats until the agent answers
3
+ // without a command (or a step cap is reached). The control flow is pure — all
4
+ // side effects (rendering the transcript, executing the command) are injected,
5
+ // which keeps it testable independently of the live agent.
6
+ /**
7
+ * Drive the loop. Each turn streams the agent reply, then looks for a command:
8
+ * if one is found and the step budget remains, it is executed, its output is
9
+ * fed back as a follow-up prompt, and the loop continues; otherwise the loop
10
+ * ends. The primer is prepended only to the very first prompt.
11
+ */
12
+ export function runAcpToolLoop(session, userPrompt, dependencies, h) {
13
+ const maxSteps = dependencies.maxSteps ?? 8;
14
+ // Start a turn's transcript entry, then issue the prompt into it.
15
+ function turn(turnPrompt, isFirst, step) {
16
+ h.startTurn(isFirst);
17
+ promptOnce(turnPrompt, isFirst, step, 0);
18
+ }
19
+ // Send one prompt for the current turn. A freshly connected agent sometimes
20
+ // returns an empty first reply (cold start — the model/provider loads lazily on
21
+ // the first prompt); retry the first turn once, reusing the same entry, before
22
+ // treating an empty reply as a final (no-command) answer.
23
+ function promptOnce(turnPrompt, isFirst, step, attempt) {
24
+ let buffer = '';
25
+ const sent = isFirst && dependencies.primer ? `${dependencies.primer}\n\n${userPrompt}` : turnPrompt;
26
+ session.prompt(sent, {
27
+ onChunk: (text) => { buffer += text; h.chunk(buffer); },
28
+ onEnd: async () => {
29
+ if (!buffer.trim() && isFirst && attempt === 0) {
30
+ promptOnce(turnPrompt, isFirst, step, attempt + 1);
31
+ return;
32
+ }
33
+ const command = dependencies.extractCommand(buffer);
34
+ const display = filterCommandFromDisplay(buffer, command);
35
+ h.endTurn(display);
36
+ if (command && step < maxSteps) {
37
+ // Only await when the command is actually async (e.g. browser); a sync command
38
+ // (db) must complete in the same tick so callers/tests observing synchronously
39
+ // see the full loop.
40
+ const returnValue = dependencies.runCommand(command);
41
+ const result = returnValue instanceof Promise ? await returnValue : returnValue;
42
+ h.ranCommand(command, result);
43
+ const followUp = `Output of \`${command}\`:\n${result}\n\n` +
44
+ 'If the task is complete, reply with the final answer and no command. ' +
45
+ 'Otherwise issue the next command. ' +
46
+ 'Be concise: do not explain what you are doing. Only output commands and the final answer.';
47
+ turn(followUp, false, step + 1);
48
+ }
49
+ else {
50
+ h.finished(command ? 'capped' : 'answered', maxSteps);
51
+ }
52
+ },
53
+ onError: (message) => h.error(message),
54
+ });
55
+ }
56
+ turn(userPrompt, true, 0);
57
+ }
58
+ function filterCommandFromDisplay(display, command) {
59
+ if (!command)
60
+ return display;
61
+ const lines = display.split('\n');
62
+ const cleaned = lines.map((l) => l.replace(/^[\s`$>]+/, '').replace(/`+\s*$/, '').trim());
63
+ const index = cleaned.indexOf(command);
64
+ if (index === -1)
65
+ return display;
66
+ lines.splice(index, 1);
67
+ // Remove adjacent code fence markers left behind by the removed command.
68
+ if (index < lines.length && /^`{3,}\s*$/.test(lines[index]))
69
+ lines.splice(index, 1);
70
+ if (index > 0 && /^`{3,}\s*$/.test(lines[index - 1]))
71
+ lines.splice(index - 1, 1);
72
+ while (lines.length > 0 && lines.at(-1).trim() === '')
73
+ lines.pop();
74
+ return lines.join('\n');
75
+ }
@@ -0,0 +1,109 @@
1
+ import { connectAcp } from './acp.js';
2
+ import { runAcpToolLoop } from './acp-loop.js';
3
+ import { extractBrowserCommand, BROWSER_PRIMER } from './browser-command.js';
4
+ import { messageBus } from './bus.js';
5
+ import { makeUpdateRunning } from './acp-runner.js';
6
+ // The ACP agent the manager connects to and the model it runs. Hardcoded for now (the only provider
7
+ // wired up); the model string drives the `provider/model` label shown in the connections panel.
8
+ const ACP_COMMAND = 'opencode';
9
+ const ACP_ARGS = ['acp'];
10
+ const ACP_MODEL = 'google/gemini-3.1-flash-lite';
11
+ // Split a `provider/model` config string into its parts; a bare `model` with no slash has no
12
+ // provider. Drives the connections-panel label.
13
+ function parseModel(model) {
14
+ const slash = model.indexOf('/');
15
+ return slash === -1 ? { model } : { provider: model.slice(0, slash), model: model.slice(slash + 1) };
16
+ }
17
+ // Owns the per-tab ACP sessions (keyed by tab label) and their reported model info. Sessions connect
18
+ // lazily on first use and persist across prompts; the manager spawns/reuses them, exposes the
19
+ // connection label, and tears them down. The prompt/tool-loop orchestration stays with the caller —
20
+ // the manager only hands back the live session.
21
+ export class AcpManager {
22
+ managers;
23
+ sessions = new Map();
24
+ info = new Map();
25
+ constructor(managers) {
26
+ this.managers = managers;
27
+ }
28
+ // Whether a tab has a connected (or connecting) ACP session. Drives the connections panel and completion.
29
+ has(label) {
30
+ return this.sessions.has(label);
31
+ }
32
+ // The `provider/model` (or bare `model`) string for a tab's session, or undefined when none is
33
+ // connected. Display-only; populated on the connection handshake.
34
+ label(label) {
35
+ const info = this.info.get(label);
36
+ if (!info)
37
+ return undefined;
38
+ return info.provider ? `${info.provider}/${info.model ?? ''}` : info.model;
39
+ }
40
+ // The tab's ACP session, connecting one on first use and reusing it thereafter. The agent runs in
41
+ // `cwd`; `hooks.onConnect` fires after the handshake, by which point the session's model info is
42
+ // recorded (so `label` resolves).
43
+ session(label, cwd, hooks) {
44
+ let session = this.sessions.get(label);
45
+ if (!session) {
46
+ const info = parseModel(ACP_MODEL);
47
+ const tab = this.managers.tab.tabs.find((t) => t.label === label);
48
+ session = connectAcp({
49
+ command: ACP_COMMAND, args: ACP_ARGS, cwd,
50
+ onError: hooks.onError,
51
+ onConnect: () => { this.info.set(label, info); hooks.onConnect(); },
52
+ env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ model: ACP_MODEL }) },
53
+ workspaceDir: tab?.workspaceDir,
54
+ offline: tab?.offline,
55
+ });
56
+ this.sessions.set(label, session);
57
+ }
58
+ return session;
59
+ }
60
+ // Kill and forget a tab's session (and its info). Returns whether one was open — the
61
+ // `connection close acp` path re-renders and reports only when it actually closed one.
62
+ close(label) {
63
+ const session = this.sessions.get(label);
64
+ if (!session)
65
+ return false;
66
+ session.kill();
67
+ this.sessions.delete(label);
68
+ this.info.delete(label);
69
+ return true;
70
+ }
71
+ // Kill every session and forget all info (app shutdown).
72
+ closeAll() {
73
+ for (const [, session] of this.sessions)
74
+ session.kill();
75
+ this.sessions.clear();
76
+ this.info.clear();
77
+ }
78
+ run(label, command, onDone) {
79
+ const prompt = command.replace(/^acp\b\s*/i, '').trim();
80
+ if (!prompt) {
81
+ this.managers.tab.append(label, { input: command, output: 'Usage: acp <prompt>.' });
82
+ return;
83
+ }
84
+ const session = this.session(label, this.managers.tab.cwdOf(label) ?? process.cwd(), {
85
+ onError: (m) => this.managers.tab.append(label, { input: '', output: `ACP: ${m}` }),
86
+ onConnect: () => messageBus.emit('state', { type: 'dirty' }),
87
+ });
88
+ const updateRunning = makeUpdateRunning(label, this.managers);
89
+ let lastAnswer = '';
90
+ runAcpToolLoop(session, prompt, {
91
+ primer: `${this.managers.database.primer}\n\n${BROWSER_PRIMER}\n\nWrite your replies in GitHub-flavored Markdown (headings, lists, tables, fenced code blocks, etc.); the tab renders them as formatted Markdown.`,
92
+ runCommand: (c) => (/^browser\b/i.test(c) ? this.managers.browser.run(label, c) : this.managers.database.runInTab(label, c)),
93
+ extractCommand: (t) => extractBrowserCommand(t) ?? this.managers.database.extract(t) ?? null,
94
+ }, {
95
+ startTurn: (isFirst) => { this.managers.tab.addBusy(label); this.managers.tab.append(label, { input: isFirst ? prompt : '', output: '', running: true, markdown: true }); },
96
+ chunk: (buffer) => updateRunning(buffer, true),
97
+ endTurn: (final) => { updateRunning(final, false); lastAnswer = final; },
98
+ ranCommand: (c, result) => this.managers.tab.append(label, { input: c, output: result, acp: true }),
99
+ finished: (reason, maxSteps) => {
100
+ this.managers.tab.deleteBusy(label);
101
+ if (reason === 'capped')
102
+ this.managers.tab.append(label, { input: '', output: `(stopped after ${maxSteps} tool steps)` });
103
+ messageBus.emit('state', { type: 'dirty' });
104
+ onDone?.(lastAnswer);
105
+ },
106
+ error: (m) => { updateRunning(`ACP error: ${m}`, false); this.managers.tab.deleteBusy(label); onDone?.(`ACP error: ${m}`); },
107
+ });
108
+ }
109
+ }
@@ -0,0 +1,18 @@
1
+ import { messageBus } from './bus.js';
2
+ export function makeUpdateRunning(label, managers) {
3
+ return (output, running) => {
4
+ const t = managers.tab.tabs.find((x) => x.label === label);
5
+ if (t) {
6
+ const log = [...t.log];
7
+ const index = log.findLastIndex((e) => e.running);
8
+ if (index !== -1)
9
+ log[index] = { ...log[index], output, running };
10
+ t.log = log;
11
+ if (!running)
12
+ managers.tab.persist(managers.tab.buildAgentState(t));
13
+ }
14
+ if (!running && output && t)
15
+ messageBus.emit('transcript', { type: 'entry:appended', tabLabel: label, entry: { input: '', output }, tab: t });
16
+ messageBus.emit('state', { type: 'dirty' });
17
+ };
18
+ }
package/dist/acp.js ADDED
@@ -0,0 +1,91 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { Readable, Writable } from 'node:stream';
3
+ import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from '@agentclientprotocol/sdk';
4
+ import { sandboxSpawn } from './sandbox.js';
5
+ import { getGithubToken } from './github-token.js';
6
+ /**
7
+ * Connect to an arbitrary ACP agent launched as a subprocess and drive it as an ACP
8
+ * client over stdio. This is agent-agnostic — the caller supplies the command to run
9
+ * (e.g. `npx @agentclientprotocol/claude-agent-acp` or `opencode acp`).
10
+ *
11
+ * MVP scope: streams `agent_message_chunk` text into the prompt handler, auto-denies tool
12
+ * permission requests, and advertises no fs/terminal capabilities (so the agent never
13
+ * calls those back).
14
+ *
15
+ * `workspaceDir`/`offline` confine the subprocess to that workspace via a Seatbelt sandbox (see
16
+ * sandbox.ts); omitted (monitor sessions never set them), the command runs exactly as before.
17
+ */
18
+ export function connectAcp(options) {
19
+ const baseEnv = options.env ? { ...process.env, ...options.env } : process.env;
20
+ const { command, args, env } = sandboxSpawn({
21
+ workspaceDir: options.workspaceDir,
22
+ offline: options.offline,
23
+ githubToken: options.workspaceDir ? getGithubToken() : undefined,
24
+ }, options.command, options.args, baseEnv);
25
+ const proc = spawn(command, args, {
26
+ cwd: options.cwd,
27
+ stdio: ['pipe', 'pipe', 'pipe'],
28
+ env,
29
+ });
30
+ proc.on('error', (error) => options.onError(`failed to start ACP agent: ${error.message}`));
31
+ // The current in-flight prompt's handlers; session updates are routed here.
32
+ let current;
33
+ const client = {
34
+ async sessionUpdate(parameters) {
35
+ const update = parameters.update;
36
+ if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
37
+ current?.onChunk(update.content.text);
38
+ }
39
+ },
40
+ // MVP: auto-deny tool permission (read-only). A real permission UI replaces this.
41
+ async requestPermission() {
42
+ return { outcome: { outcome: 'cancelled' } };
43
+ },
44
+ };
45
+ const input = Readable.toWeb(proc.stdout);
46
+ const output = Writable.toWeb(proc.stdin);
47
+ const conn = new ClientSideConnection(() => client, ndJsonStream(output, input));
48
+ let sessionId;
49
+ let ready;
50
+ const ensureSession = () => {
51
+ ready ??= (async () => {
52
+ const init = await conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} });
53
+ const response = await conn.newSession({ cwd: options.cwd, mcpServers: [] });
54
+ sessionId = response.sessionId;
55
+ // Provider from the agent's reported name (fallback: the command basename);
56
+ // model is best-effort from the session's current mode (ACP has no model field).
57
+ const provider = init.agentInfo?.name ?? options.command.replace(/^.*[\\/]/, '');
58
+ const modes = response.modes;
59
+ const model = modes
60
+ ? modes.availableModes.find((m) => m.id === modes.currentModeId)?.name ?? modes.currentModeId
61
+ : undefined;
62
+ options.onConnect?.({ provider, model });
63
+ })();
64
+ return ready;
65
+ };
66
+ return {
67
+ prompt: (text, handlers) => {
68
+ current = handlers;
69
+ void (async () => {
70
+ try {
71
+ await ensureSession();
72
+ const response = await conn.prompt({ sessionId: sessionId, prompt: [{ type: 'text', text }] });
73
+ handlers.onEnd(response.stopReason);
74
+ }
75
+ catch (error) {
76
+ handlers.onError(error instanceof Error ? error.message : String(error));
77
+ }
78
+ finally {
79
+ if (current === handlers)
80
+ current = undefined;
81
+ }
82
+ })();
83
+ },
84
+ kill: () => {
85
+ try {
86
+ proc.kill();
87
+ }
88
+ catch { /* already gone */ }
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,29 @@
1
+ import agentNames from '../../agent-names.json' with { type: 'json' };
2
+ import { getConfig } from '../config.js';
3
+ export function resolveAgentName(input, existingLabels) {
4
+ const trimmed = input.trim();
5
+ const match = trimmed.match(/^agent\s+(.+)/i);
6
+ if (match) {
7
+ return match[1].trim().toLowerCase().slice(0, getConfig().tabNameMaxLength);
8
+ }
9
+ const lowerExisting = new Set(existingLabels.map((l) => l.toLowerCase()));
10
+ const pool = agentNames.filter((n) => !lowerExisting.has(n));
11
+ if (pool.length === 0)
12
+ return null;
13
+ return pool[Math.floor(Math.random() * pool.length)];
14
+ }
15
+ export function parseAgentCommand(input) {
16
+ const trimmed = input.trim();
17
+ const isWorkspace = /\s(-w|--workspace)\b/i.test(trimmed);
18
+ const isOffline = /\s--offline\b/i.test(trimmed);
19
+ const stripped = trimmed
20
+ .replaceAll(/\s+(-w|--workspace|--offline)(?=\s|$)/gi, '')
21
+ .replaceAll(/\s+/g, ' ')
22
+ .trim();
23
+ const nameMatch = stripped.match(/^agent\s+(.+)/i);
24
+ return {
25
+ name: nameMatch ? nameMatch[1].trim().toLowerCase().slice(0, getConfig().tabNameMaxLength) : '',
26
+ workspace: isWorkspace,
27
+ offline: isOffline,
28
+ };
29
+ }
@@ -0,0 +1,71 @@
1
+ export class AgentCommunicationManager {
2
+ managers;
3
+ queues = new Map();
4
+ processing = new Set();
5
+ nextId = 0;
6
+ constructor(managers) {
7
+ this.managers = managers;
8
+ }
9
+ // Recipients may be addressed by their label or by their display alias (see `rename`);
10
+ // either way, routing and queuing key off the tab's canonical label.
11
+ send(message) {
12
+ const to = message.to.toLowerCase();
13
+ const target = this.managers.tab.tabs.find((t) => t.label.toLowerCase() === to || t.title?.toLowerCase() === to);
14
+ if (!target)
15
+ return false;
16
+ const full = { ...message, to: target.label, id: ++this.nextId };
17
+ const q = this.queues.get(target.label) ?? [];
18
+ q.push(full);
19
+ this.queues.set(target.label, q);
20
+ this.pump(target.label);
21
+ return true;
22
+ }
23
+ pump(label) {
24
+ if (this.processing.has(label))
25
+ return;
26
+ const queue = this.queues.get(label);
27
+ if (!queue || queue.length === 0)
28
+ return;
29
+ this.processing.add(label);
30
+ const message = queue.shift();
31
+ this.handle(message, () => {
32
+ this.processing.delete(label);
33
+ if ((this.queues.get(label)?.length ?? 0) > 0)
34
+ setTimeout(() => this.pump(label), 0);
35
+ });
36
+ }
37
+ handle(message, done) {
38
+ const agentColor = (label) => this.managers.tab.tabs.find((t) => t.label === label)?.dotColor ?? '#e4e5e7';
39
+ const appendContext = (label, text) => {
40
+ this.managers.tab.appendContext(label, text);
41
+ const tab = this.managers.tab.tabs.find((t) => t.label === label);
42
+ if (tab)
43
+ this.managers.tab.persist(this.managers.tab.buildAgentState(tab, { schedule: this.managers.schedule.get(tab.label) }));
44
+ };
45
+ if (message.kind === 'info') {
46
+ this.managers.tab.append(message.to, { input: '', output: message.text, from: message.from, fromColor: agentColor(message.from), msgKind: 'info' });
47
+ appendContext(message.to, `${message.from}: ${message.text}`);
48
+ done();
49
+ return;
50
+ }
51
+ if (message.kind === 'response') {
52
+ this.managers.tab.append(message.to, { input: '', output: message.text, from: `response from ${message.from}`, fromColor: agentColor(message.from), msgKind: 'response' });
53
+ appendContext(message.to, `${message.from}: ${message.text}`);
54
+ done();
55
+ return;
56
+ }
57
+ if (message.kind === 'command') {
58
+ this.managers.tab.append(message.to, { input: '', output: `sent command: ${message.text}`, from: message.from, fromColor: agentColor(message.from), msgKind: 'info' });
59
+ this.managers.capture.run(message.to, message.text, () => done());
60
+ return;
61
+ }
62
+ if (message.kind === 'request') {
63
+ this.managers.tab.append(message.to, { input: '', output: `sent request: ${message.text}`, from: message.from, fromColor: agentColor(message.from), msgKind: 'info' });
64
+ this.managers.capture.run(message.to, message.text, (output) => {
65
+ this.send({ from: message.to, to: message.from, kind: 'response', text: output });
66
+ done();
67
+ });
68
+ return;
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,58 @@
1
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ let stateDirectory = '';
4
+ export function initAgentStateDirectory(projectDirectory) {
5
+ stateDirectory = path.join(projectDirectory, '.janissary', 'state');
6
+ }
7
+ export function ensureStateDirectory() {
8
+ mkdirSync(stateDirectory, { recursive: true });
9
+ }
10
+ const VALID_NAME = /^[\w-]+$/;
11
+ export function agentStatePath(name) {
12
+ if (!VALID_NAME.test(name))
13
+ throw new Error(`Invalid agent name: "${name}"`);
14
+ return path.join(stateDirectory, `${name}.json`);
15
+ }
16
+ function isAgentState(x) {
17
+ return (typeof x === 'object' && x !== null &&
18
+ typeof x.name === 'string' &&
19
+ typeof x.dotColor === 'string' &&
20
+ typeof x.active === 'boolean');
21
+ }
22
+ export function loadAgentState(name) {
23
+ const path = agentStatePath(name);
24
+ if (!existsSync(path))
25
+ return undefined;
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
28
+ return isAgentState(parsed) ? parsed : undefined;
29
+ }
30
+ catch {
31
+ // skip invalid agent state files
32
+ }
33
+ }
34
+ export function saveAgentState(state) {
35
+ ensureStateDirectory();
36
+ writeFileSync(agentStatePath(state.name), JSON.stringify(state, null, 2) + '\n');
37
+ }
38
+ export function clearStateDirectory() {
39
+ if (!stateDirectory)
40
+ return;
41
+ try {
42
+ rmSync(stateDirectory, { recursive: true, force: true });
43
+ }
44
+ catch { /* ignore */ }
45
+ }
46
+ export function listAgentStates() {
47
+ if (!existsSync(stateDirectory))
48
+ return [];
49
+ try {
50
+ return readdirSync(stateDirectory)
51
+ .filter((f) => f.endsWith('.json'))
52
+ .map((f) => loadAgentState(f.replace(/\.json$/, '')))
53
+ .filter((s) => s !== undefined);
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
@@ -0,0 +1,29 @@
1
+ import agentNames from '../agent-names.json' with { type: 'json' };
2
+ import { getConfig } from './config.js';
3
+ export function resolveAgentName(input, existingLabels) {
4
+ const trimmed = input.trim();
5
+ const match = trimmed.match(/^agent\s+(.+)/i);
6
+ if (match) {
7
+ return match[1].trim().toLowerCase().slice(0, getConfig().tabNameMaxLength);
8
+ }
9
+ const lowerExisting = new Set(existingLabels.map((l) => l.toLowerCase()));
10
+ const pool = agentNames.filter((n) => !lowerExisting.has(n));
11
+ if (pool.length === 0)
12
+ return null;
13
+ return pool[Math.floor(Math.random() * pool.length)];
14
+ }
15
+ export function parseAgentCommand(input) {
16
+ const trimmed = input.trim();
17
+ const isWorkspace = /\s(-w|--workspace)\b/i.test(trimmed);
18
+ const isOffline = /\s--offline\b/i.test(trimmed);
19
+ const stripped = trimmed
20
+ .replaceAll(/\s+(-w|--workspace|--offline)(?=\s|$)/gi, '')
21
+ .replaceAll(/\s+/g, ' ')
22
+ .trim();
23
+ const nameMatch = stripped.match(/^agent\s+(.+)/i);
24
+ return {
25
+ name: nameMatch ? nameMatch[1].trim().toLowerCase().slice(0, getConfig().tabNameMaxLength) : '',
26
+ workspace: isWorkspace,
27
+ offline: isOffline,
28
+ };
29
+ }
@@ -0,0 +1,71 @@
1
+ export class AgentCommunicationManager {
2
+ managers;
3
+ queues = new Map();
4
+ processing = new Set();
5
+ nextId = 0;
6
+ constructor(managers) {
7
+ this.managers = managers;
8
+ }
9
+ // Recipients may be addressed by their label or by their display alias (see `rename`);
10
+ // either way, routing and queuing key off the tab's canonical label.
11
+ send(message) {
12
+ const to = message.to.toLowerCase();
13
+ const target = this.managers.tab.tabs.find((t) => t.label.toLowerCase() === to || t.title?.toLowerCase() === to);
14
+ if (!target)
15
+ return false;
16
+ const full = { ...message, to: target.label, id: ++this.nextId };
17
+ const q = this.queues.get(target.label) ?? [];
18
+ q.push(full);
19
+ this.queues.set(target.label, q);
20
+ this.pump(target.label);
21
+ return true;
22
+ }
23
+ pump(label) {
24
+ if (this.processing.has(label))
25
+ return;
26
+ const queue = this.queues.get(label);
27
+ if (!queue || queue.length === 0)
28
+ return;
29
+ this.processing.add(label);
30
+ const message = queue.shift();
31
+ this.handle(message, () => {
32
+ this.processing.delete(label);
33
+ if ((this.queues.get(label)?.length ?? 0) > 0)
34
+ setTimeout(() => this.pump(label), 0);
35
+ });
36
+ }
37
+ handle(message, done) {
38
+ const agentColor = (label) => this.managers.tab.tabs.find((t) => t.label === label)?.dotColor ?? '#e4e5e7';
39
+ const appendContext = (label, text) => {
40
+ this.managers.tab.appendContext(label, text);
41
+ const tab = this.managers.tab.tabs.find((t) => t.label === label);
42
+ if (tab)
43
+ this.managers.tab.persist(this.managers.tab.buildAgentState(tab, { schedule: this.managers.schedule.get(tab.label) }));
44
+ };
45
+ if (message.kind === 'info') {
46
+ this.managers.tab.append(message.to, { input: '', output: message.text, from: message.from, fromColor: agentColor(message.from), msgKind: 'info' });
47
+ appendContext(message.to, `${message.from}: ${message.text}`);
48
+ done();
49
+ return;
50
+ }
51
+ if (message.kind === 'response') {
52
+ this.managers.tab.append(message.to, { input: '', output: message.text, from: `response from ${message.from}`, fromColor: agentColor(message.from), msgKind: 'response' });
53
+ appendContext(message.to, `${message.from}: ${message.text}`);
54
+ done();
55
+ return;
56
+ }
57
+ if (message.kind === 'command') {
58
+ this.managers.tab.append(message.to, { input: '', output: `sent command: ${message.text}`, from: message.from, fromColor: agentColor(message.from), msgKind: 'info' });
59
+ this.managers.capture.run(message.to, message.text, () => done());
60
+ return;
61
+ }
62
+ if (message.kind === 'request') {
63
+ this.managers.tab.append(message.to, { input: '', output: `sent request: ${message.text}`, from: message.from, fromColor: agentColor(message.from), msgKind: 'info' });
64
+ this.managers.capture.run(message.to, message.text, (output) => {
65
+ this.send({ from: message.to, to: message.from, kind: 'response', text: output });
66
+ done();
67
+ });
68
+ return;
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,58 @@
1
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ let stateDirectory = '';
4
+ export function initAgentStateDirectory(projectDirectory) {
5
+ stateDirectory = path.join(projectDirectory, '.janissary', 'state');
6
+ }
7
+ export function ensureStateDirectory() {
8
+ mkdirSync(stateDirectory, { recursive: true });
9
+ }
10
+ const VALID_NAME = /^[\w-]+$/;
11
+ export function agentStatePath(name) {
12
+ if (!VALID_NAME.test(name))
13
+ throw new Error(`Invalid agent name: "${name}"`);
14
+ return path.join(stateDirectory, `${name}.json`);
15
+ }
16
+ function isAgentState(x) {
17
+ return (typeof x === 'object' && x !== null &&
18
+ typeof x.name === 'string' &&
19
+ typeof x.dotColor === 'string' &&
20
+ typeof x.active === 'boolean');
21
+ }
22
+ export function loadAgentState(name) {
23
+ const path = agentStatePath(name);
24
+ if (!existsSync(path))
25
+ return undefined;
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
28
+ return isAgentState(parsed) ? parsed : undefined;
29
+ }
30
+ catch {
31
+ // skip invalid agent state files
32
+ }
33
+ }
34
+ export function saveAgentState(state) {
35
+ ensureStateDirectory();
36
+ writeFileSync(agentStatePath(state.name), JSON.stringify(state, null, 2) + '\n');
37
+ }
38
+ export function clearStateDirectory() {
39
+ if (!stateDirectory)
40
+ return;
41
+ try {
42
+ rmSync(stateDirectory, { recursive: true, force: true });
43
+ }
44
+ catch { /* ignore */ }
45
+ }
46
+ export function listAgentStates() {
47
+ if (!existsSync(stateDirectory))
48
+ return [];
49
+ try {
50
+ return readdirSync(stateDirectory)
51
+ .filter((f) => f.endsWith('.json'))
52
+ .map((f) => loadAgentState(f.replace(/\.json$/, '')))
53
+ .filter((s) => s !== undefined);
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
@@ -0,0 +1,13 @@
1
+ // Names of the application color themes available for `theme <name>`. Shared between the server
2
+ // (persisted config, command validation) and the web client (picker list, `data-theme` values) via
3
+ // the `@shared` alias, so this file must stay free of Node imports. The palettes themselves live
4
+ // only in web/src/theme.css as `[data-theme="<name>"]` blocks.
5
+ export const APP_THEMES = [
6
+ 'dark',
7
+ 'light',
8
+ 'solarized-dark',
9
+ 'solarized-light',
10
+ 'nord',
11
+ 'dracula',
12
+ ];
13
+ export const DEFAULT_APP_THEME = 'dark';