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,22 @@
1
+ import { connectAcp } from './acp.js';
2
+ export function spawnMonitorSession(persona, cwd, hooks) {
3
+ const { harness, model, variant } = persona.harness;
4
+ if (harness === 'opencode') {
5
+ // Only `model` goes into the config: opencode rejects unknown keys, and a rejected
6
+ // config kills the subprocess on startup. The directive's variant is not mapped yet.
7
+ return connectAcp({
8
+ command: 'opencode', args: ['acp'], cwd,
9
+ onError: hooks.onError,
10
+ onConnect: hooks.onConnect,
11
+ env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ model }) },
12
+ });
13
+ }
14
+ // claude: the Claude Code ACP adapter. Model and thinking effort are passed via its
15
+ // environment (best-effort mapping; the adapter ignores variables it doesn't know).
16
+ return connectAcp({
17
+ command: 'npx', args: ['@zed-industries/claude-code-acp'], cwd,
18
+ onError: hooks.onError,
19
+ onConnect: hooks.onConnect,
20
+ env: { ANTHROPIC_MODEL: model, CLAUDE_THINKING_EFFORT: variant },
21
+ });
22
+ }
@@ -0,0 +1,18 @@
1
+ // Read-only projections of the live monitor registry: the `monitors` command listing
2
+ // and the connections-panel rows.
3
+ export function listMonitors(monitors) {
4
+ return [...monitors].map((reg) => {
5
+ const targets = reg.targets.map((t) => (t.kind === 'tab' ? t.label : `group:${t.group}`)).join(', ');
6
+ const mode = reg.inline ? 'inline' : 'external';
7
+ return `${reg.persona.name}: ${targets} ← ${reg.owner} (${mode}, ${reg.delivered} suggestion${reg.delivered === 1 ? '' : 's'})`;
8
+ });
9
+ }
10
+ // Rows for a tab's monitors (e.g. `monitor:security (opencode/…)`).
11
+ export function monitorConnections(monitors, owner) {
12
+ return [...monitors]
13
+ .filter((reg) => reg.owner === owner)
14
+ .map((reg) => {
15
+ const info = reg.info ? ` (${[reg.info.provider, reg.info.model].filter(Boolean).join('/')})` : '';
16
+ return { text: `monitor:${reg.persona.name}${info}`, kind: 'acp' };
17
+ });
18
+ }
@@ -0,0 +1,233 @@
1
+ import { messageBus } from './bus.js';
2
+ import { loadPersona } from './personas.js';
3
+ import { parseSuggestion } from './monitor-parsing.js';
4
+ import { openMonitorTab, closeMonitorTab, pushSuggestion, findSuggestion, removeSuggestion } from './monitor-window.js';
5
+ import { openMonitorSession, respawnMonitorSession } from './monitor-session.js';
6
+ import { spawnMonitorSession } from './monitor-acp.js';
7
+ import { validateTargets, matchesTargets, targetColor } from './monitor-targets.js';
8
+ import { listMonitors, monitorConnections } from './monitor-info.js';
9
+ export const MONITOR_FLUSH_MS = 30_000;
10
+ // Marks inline suggestion entries so monitors never feed on their own output.
11
+ export const SUGGESTION_PREFIX = '💡';
12
+ // Owns all live monitors, keyed by `${ownerLabel}:${persona}`. Each monitor is a
13
+ // dedicated, tool-less ACP session primed with its persona; transcript entries from its
14
+ // targets buffer up and flush as one prompt every 30s (never when the buffer is empty,
15
+ // never while a previous prompt is still streaming). Suggestions route to the owner
16
+ // tab's transcript (inline mode) or the persona's reporting tab (external mode).
17
+ export class MonitorManager {
18
+ managers;
19
+ spawn;
20
+ flushMs;
21
+ monitors = new Map();
22
+ counter = 0;
23
+ constructor(managers, spawn = spawnMonitorSession, flushMs = MONITOR_FLUSH_MS) {
24
+ this.managers = managers;
25
+ this.spawn = spawn;
26
+ this.flushMs = flushMs;
27
+ }
28
+ // Start a monitor; returns an error message, or null on success. No targets = inline
29
+ // mode (watch the owner tab, report into its transcript).
30
+ start(owner, personaName, targets) {
31
+ const key = `${owner}:${personaName}`;
32
+ if (this.monitors.has(key))
33
+ return `Already monitoring with persona "${personaName}".`;
34
+ const inline = targets.length === 0;
35
+ const resolved = inline ? [{ kind: 'tab', label: owner }] : targets;
36
+ const targetError = validateTargets(this.managers.tab.tabs, personaName, inline, resolved);
37
+ if (targetError)
38
+ return targetError;
39
+ let persona;
40
+ try {
41
+ persona = loadPersona(personaName);
42
+ }
43
+ catch (error) {
44
+ return error instanceof Error ? error.message : String(error);
45
+ }
46
+ const reg = {
47
+ owner, inline, persona, targets: resolved, buffer: [], inFlight: true, delivered: 0,
48
+ session: undefined, timer: undefined, subs: [],
49
+ };
50
+ this.openSession(reg);
51
+ this.subscribe(key, reg);
52
+ for (const target of resolved) {
53
+ const tabs = target.kind === 'tab'
54
+ ? this.managers.tab.tabs.filter((t) => t.label === target.label)
55
+ : this.managers.tab.tabs.filter((t) => t.group === target.group);
56
+ for (const t of tabs) {
57
+ for (const entry of t.log)
58
+ reg.buffer.push({ tabLabel: t.label, entry });
59
+ }
60
+ }
61
+ reg.timer = setInterval(() => this.flush(key), this.flushMs);
62
+ this.monitors.set(key, reg);
63
+ // External mode: open the reporting tab right away (empty feed) so starting the
64
+ // monitor is visible immediately, not only when the first suggestion lands.
65
+ if (!inline)
66
+ openMonitorTab(this.managers, personaName, targetColor(this.managers.tab.tabs, resolved));
67
+ return null;
68
+ }
69
+ // Spawn the monitor's dedicated session and prime it with the persona body + reply
70
+ // format; `inFlight` holds flushes off until priming settles.
71
+ openSession(reg) {
72
+ openMonitorSession(reg, this.managers, this.spawn);
73
+ }
74
+ // A prompt failed (typically the ACP subprocess died). Replace the session with a
75
+ // fresh, re-primed one so the monitor recovers instead of staying dead.
76
+ respawn(reg) {
77
+ respawnMonitorSession(reg, this.managers, this.spawn);
78
+ }
79
+ subscribe(key, reg) {
80
+ reg.subs.push(messageBus.on('transcript', 'entry:appended', (event) => {
81
+ if (event.type !== 'entry:appended' || !matchesTargets(this.managers.tab.tabs, reg.targets, event.tabLabel))
82
+ return;
83
+ // Never feed a monitor its own (or a sibling monitor's) inline suggestions.
84
+ if (event.entry.output.startsWith(SUGGESTION_PREFIX))
85
+ return;
86
+ reg.buffer.push({ tabLabel: event.tabLabel, entry: event.entry });
87
+ }), messageBus.on('transcript', 'tab:removed', (event) => {
88
+ if (event.type !== 'tab:removed')
89
+ return;
90
+ if (event.tabLabel === reg.owner) {
91
+ this.handleOwnerClosed(reg.owner);
92
+ return;
93
+ }
94
+ // Only tab targets drop out; group targets persist (the group may gain new tabs).
95
+ if (reg.targets.some((t) => t.kind === 'tab' && t.label === event.tabLabel)) {
96
+ this.stop(reg.owner, reg.persona.name, { kind: 'tab', label: event.tabLabel });
97
+ }
98
+ }));
99
+ }
100
+ // The 30-second batch. No new transcripts → no ACP query at all; also skipped while a
101
+ // previous prompt (including the persona priming) is still streaming.
102
+ flush(key) {
103
+ const reg = this.monitors.get(key);
104
+ if (!reg || reg.inFlight || reg.buffer.length === 0)
105
+ return;
106
+ const batch = reg.buffer;
107
+ reg.buffer = [];
108
+ const body = batch
109
+ .map(({ tabLabel, entry }) => `[${tabLabel}]\n${entry.input}\n${entry.output}`.trim())
110
+ .join('\n\n');
111
+ reg.inFlight = true;
112
+ let reply = '';
113
+ reg.session.prompt(`[Monitor update]\n${body}`, {
114
+ onChunk: (text) => { reply += text; },
115
+ onEnd: () => {
116
+ reg.inFlight = false;
117
+ const suggestion = parseSuggestion(reply);
118
+ if (suggestion)
119
+ this.deliver(reg, batch.at(-1).tabLabel, suggestion);
120
+ },
121
+ onError: (message) => {
122
+ this.managers.tab.append(reg.owner, { input: '', output: `monitor ${reg.persona.name}: ${message} — restarting monitor session` });
123
+ this.respawn(reg);
124
+ },
125
+ });
126
+ }
127
+ deliver(reg, about, parsed) {
128
+ reg.delivered += 1;
129
+ const suggestion = {
130
+ ...parsed, id: `s-${++this.counter}`, timestamp: Date.now(), persona: reg.persona.name, about,
131
+ };
132
+ if (reg.inline) {
133
+ const command = suggestion.command ? `\n${suggestion.command}` : '';
134
+ this.managers.tab.append(reg.owner, { input: '', output: `${SUGGESTION_PREFIX} ${reg.persona.name}: ${suggestion.text}${command}` });
135
+ return;
136
+ }
137
+ pushSuggestion(this.managers, reg.persona.name, targetColor(this.managers.tab.tabs, reg.targets), suggestion);
138
+ }
139
+ // Query a running monitor's ACP session directly; the reply lands in the owner tab's
140
+ // transcript. Shares the `inFlight` slot with flushes, so a question never interleaves
141
+ // with a monitor-update prompt. Returns an error message, or null when the question is
142
+ // on its way.
143
+ ask(owner, personaName, question) {
144
+ const reg = this.monitors.get(`${owner}:${personaName}`);
145
+ if (!reg)
146
+ return `No "${personaName}" monitor running from this tab.`;
147
+ if (reg.inFlight)
148
+ return `The ${personaName} monitor is busy; try again in a moment.`;
149
+ reg.inFlight = true;
150
+ let reply = '';
151
+ this.managers.tab.startRunning(owner, `monitor ask ${personaName} ${question}`);
152
+ reg.session.prompt(`[Question from the user]\n${question}\n\nAnswer directly; the suggestion format does not apply to this reply.`, {
153
+ onChunk: (text) => { reply += text; },
154
+ onEnd: () => {
155
+ reg.inFlight = false;
156
+ // The 💡 prefix keeps the reply out of monitor buffers (like inline suggestions).
157
+ this.managers.tab.finishRunning(owner, `${SUGGESTION_PREFIX} ${personaName}: ${reply.trim() || '(no reply)'}`);
158
+ },
159
+ onError: (message) => {
160
+ this.managers.tab.finishRunning(owner, `monitor ${personaName}: ${message} — restarting monitor session`);
161
+ this.respawn(reg);
162
+ },
163
+ });
164
+ return null;
165
+ }
166
+ // Thumbs up/down on a reporting-tab suggestion. The rating is fed back to the monitor
167
+ // through its normal batched prompt channel (no extra ACP round-trip), so the AI learns
168
+ // what the user found useful. Rating a suggestion means the user is done with it, so
169
+ // either direction removes it from the feed.
170
+ rate(id, up) {
171
+ const suggestion = findSuggestion(this.managers, id);
172
+ if (!suggestion)
173
+ return;
174
+ const reg = [...this.monitors.values()].find((r) => !r.inline && r.persona.name === suggestion.persona);
175
+ reg?.buffer.push({
176
+ tabLabel: suggestion.about,
177
+ entry: { input: '', output: `[user feedback] The user rated your suggestion "${suggestion.text}" as ${up ? 'helpful (thumbs up)' : 'not helpful (thumbs down)'}.` },
178
+ });
179
+ removeSuggestion(this.managers, id);
180
+ }
181
+ // Stop one persona's monitor (or drop a single target from it). Returns false when no
182
+ // such monitor exists.
183
+ stop(owner, personaName, target) {
184
+ const key = `${owner}:${personaName}`;
185
+ const reg = this.monitors.get(key);
186
+ if (!reg)
187
+ return false;
188
+ if (target && !reg.inline) {
189
+ reg.targets = reg.targets.filter((t) => JSON.stringify(t) !== JSON.stringify(target));
190
+ if (reg.targets.length > 0)
191
+ return true;
192
+ }
193
+ for (const sub of reg.subs)
194
+ sub.unsubscribe();
195
+ clearInterval(reg.timer);
196
+ reg.session.kill();
197
+ this.monitors.delete(key);
198
+ return true;
199
+ }
200
+ // The owning agent tab closed: stop its monitors, and close any reporting tab that no
201
+ // longer has a live monitor feeding it (another tab may still run the same persona).
202
+ handleOwnerClosed(owner) {
203
+ const personas = [...this.monitors.values()]
204
+ .filter((reg) => reg.owner === owner && !reg.inline)
205
+ .map((reg) => reg.persona.name);
206
+ this.stopAll(owner);
207
+ for (const name of personas) {
208
+ const stillFed = [...this.monitors.values()].some((reg) => !reg.inline && reg.persona.name === name);
209
+ if (!stillFed)
210
+ closeMonitorTab(this.managers, name);
211
+ }
212
+ }
213
+ stopAll(owner) {
214
+ const mine = [...this.monitors.values()].filter((r) => r.owner === owner);
215
+ for (const reg of mine)
216
+ this.stop(reg.owner, reg.persona.name);
217
+ return mine.length;
218
+ }
219
+ // Lines for the `monitors` command.
220
+ list() {
221
+ return listMonitors(this.monitors.values());
222
+ }
223
+ // Connections-panel rows for a tab's monitors (e.g. `monitor:security (opencode/…)`).
224
+ connectionsFor(owner) {
225
+ return monitorConnections(this.monitors.values(), owner);
226
+ }
227
+ closeAll() {
228
+ // Snapshot first: `stop` mutates the map while we iterate.
229
+ const all = [...this.monitors.values()];
230
+ for (const reg of all)
231
+ this.stop(reg.owner, reg.persona.name);
232
+ }
233
+ }
@@ -0,0 +1,68 @@
1
+ // A target argument: `group:<n>` or a tab label.
2
+ function parseTarget(word) {
3
+ const group = /^group:(\d+)$/i.exec(word);
4
+ if (group)
5
+ return { kind: 'group', group: Number(group[1]) };
6
+ if (word.includes(':'))
7
+ return { error: `Bad target "${word}" (expected a tab label or group:<n>).` };
8
+ return { kind: 'tab', label: word };
9
+ }
10
+ // `monitor <persona> [target...]` — no targets means inline mode (watch the current tab).
11
+ // `monitor ask <persona> <question>` — query the running monitor's ACP directly.
12
+ export function parseMonitorCommand(input) {
13
+ const words = input.trim().split(/\s+/).slice(1);
14
+ if (words[0] === 'ask') {
15
+ const persona = words[1];
16
+ const question = words.slice(2).join(' ');
17
+ if (!persona || !question)
18
+ return { error: 'Usage: monitor ask <persona> <question>' };
19
+ return { ask: true, persona, question };
20
+ }
21
+ const persona = words[0];
22
+ if (!persona)
23
+ return { error: 'Usage: monitor <persona> [tab|group:<n> ...]' };
24
+ if (words[1] === 'ask')
25
+ return { error: `Did you mean: monitor ask ${persona} <question>?` };
26
+ const targets = [];
27
+ for (const word of words.slice(1)) {
28
+ const target = parseTarget(word);
29
+ if ('error' in target)
30
+ return target;
31
+ targets.push(target);
32
+ }
33
+ return { persona, targets };
34
+ }
35
+ // `unmonitor --all` | `unmonitor <persona> [target]`
36
+ export function parseUnmonitorCommand(input) {
37
+ const words = input.trim().split(/\s+/).slice(1);
38
+ if (words[0] === '--all')
39
+ return { all: true };
40
+ const persona = words[0];
41
+ if (!persona)
42
+ return { error: 'Usage: unmonitor <persona> [tab|group:<n>] | unmonitor --all' };
43
+ if (words.length === 1)
44
+ return { persona };
45
+ const target = parseTarget(words[1]);
46
+ if ('error' in target)
47
+ return target;
48
+ return { persona, target };
49
+ }
50
+ // Extract a suggestion from the monitoring AI's reply. The reply may contain anything;
51
+ // only the marker lines count:
52
+ // [SUGGESTION]: <text>
53
+ // [COMMAND]: <optional command>
54
+ // No marker → no suggestion (the persona is told silence is fine).
55
+ export function parseSuggestion(reply) {
56
+ const text = /^\[SUGGESTION]:\s*(.+)$/m.exec(reply)?.[1]?.trim();
57
+ if (!text)
58
+ return null;
59
+ const command = /^\[COMMAND]:\s*(.+)$/m.exec(reply)?.[1]?.trim();
60
+ return command ? { text, command } : { text };
61
+ }
62
+ // The output-format instructions appended to every persona's startup prompt.
63
+ export const SUGGESTION_FORMAT = [
64
+ 'When you have a suggestion, reply with exactly this format:',
65
+ '[SUGGESTION]: <one short sentence>',
66
+ '[COMMAND]: <a single command the user could run, only if one clearly applies>',
67
+ 'When you have nothing useful to say, reply with the single word: OK',
68
+ ].join('\n');
@@ -0,0 +1,19 @@
1
+ import { messageBus } from './bus.js';
2
+ import { spawnMonitorSession } from './monitor-acp.js';
3
+ import { SUGGESTION_FORMAT } from './monitor-parsing.js';
4
+ export function openMonitorSession(reg, managers, spawn = spawnMonitorSession) {
5
+ reg.inFlight = true;
6
+ reg.session = spawn(reg.persona, managers.tab.cwdOf(reg.owner) ?? process.cwd(), {
7
+ onError: (message) => managers.tab.append(reg.owner, { input: '', output: `monitor ${reg.persona.name}: ${message}` }),
8
+ onConnect: (info) => { reg.info = info; messageBus.emit('state', { type: 'dirty' }); },
9
+ });
10
+ reg.session.prompt(`${reg.persona.body}\n\n${SUGGESTION_FORMAT}`, {
11
+ onChunk: () => { },
12
+ onEnd: () => { reg.inFlight = false; },
13
+ onError: () => { reg.inFlight = false; },
14
+ });
15
+ }
16
+ export function respawnMonitorSession(reg, managers, spawn = spawnMonitorSession) {
17
+ reg.session.kill();
18
+ openMonitorSession(reg, managers, spawn);
19
+ }
@@ -0,0 +1,37 @@
1
+ // Target helpers for the monitor manager: validation, live matching, and the color a
2
+ // reporting tab inherits. All operate on the current tab list — group targets are
3
+ // resolved at event time, so group membership stays dynamic.
4
+ export function validateTargets(tabs, personaName, inline, targets) {
5
+ for (const target of targets) {
6
+ if (target.kind === 'tab' && tabs.every((t) => t.label !== target.label || t.view === 'monitor')) {
7
+ return `No tab named "${target.label}".`;
8
+ }
9
+ if (target.kind === 'group' && tabs.every((t) => t.group !== target.group)) {
10
+ return `No group ${target.group}.`;
11
+ }
12
+ }
13
+ // The reporting tab reuses the persona name as its label; an action tab holding that
14
+ // label would collide with by-label lookups (e.g. runSuggestion's dispatch).
15
+ if (!inline && tabs.some((t) => t.label === personaName && t.view !== 'monitor')) {
16
+ return `A tab named "${personaName}" already exists; rename it or pick another persona.`;
17
+ }
18
+ return null;
19
+ }
20
+ export function matchesTargets(tabs, targets, tabLabel) {
21
+ const tab = tabs.find((t) => t.label === tabLabel);
22
+ if (!tab || tab.view === 'monitor')
23
+ return false;
24
+ return targets.some((t) => (t.kind === 'tab' ? t.label === tabLabel : t.group === tab.group));
25
+ }
26
+ // Reporting tabs carry the monitored tab's color: the first tab target's dot color, or
27
+ // the first member of the first group target.
28
+ export function targetColor(tabs, targets) {
29
+ for (const target of targets) {
30
+ const tab = target.kind === 'tab'
31
+ ? tabs.find((t) => t.label === target.label)
32
+ : tabs.find((t) => t.group === target.group);
33
+ if (tab)
34
+ return target.kind === 'group' ? tab.groupColor : tab.dotColor;
35
+ }
36
+ return '#5b9cff';
37
+ }
@@ -0,0 +1,81 @@
1
+ import { makeTab } from './tab.js';
2
+ import { messageBus } from './bus.js';
3
+ // Monitor reporting tabs: each external-mode monitor gets its own view-only tab
4
+ // (`view: 'monitor'`) named after its persona (e.g. `security`, `quality`) and colored
5
+ // after the tab it monitors — the reporting strip and body left-border carry that color.
6
+ // Reporting tabs render in the reporting section below the command bar, never in the
7
+ // action strip, and accept no commands; their only interactions are the per-suggestion
8
+ // Run/Dismiss buttons (RPCs handled here).
9
+ const makeMonitorTab = (name, dotColor, number) => ({
10
+ // Group 0: reporting tabs sit outside the action-tab group system.
11
+ ...makeTab(name, dotColor, number, [], [], undefined, 0, dotColor),
12
+ view: 'monitor',
13
+ title: name,
14
+ monitor: { suggestions: [] },
15
+ });
16
+ // A unique label for a new monitor's reporting tab: the persona name, suffixed
17
+ // (`assistant-2`, …) when that label is already taken by any tab. Each monitor instance
18
+ // gets its own window, so the same persona can watch different targets side by side.
19
+ export function allocateMonitorLabel(managers, persona) {
20
+ const used = new Set(managers.tab.tabs.map((t) => t.label));
21
+ if (!used.has(persona))
22
+ return persona;
23
+ let n = 2;
24
+ while (used.has(`${persona}-${n}`))
25
+ n++;
26
+ return `${persona}-${n}`;
27
+ }
28
+ // All monitor reporting tabs currently open.
29
+ export function monitorTabs(managers) {
30
+ return managers.tab.tabs.filter((t) => t.view === 'monitor');
31
+ }
32
+ // Open the named monitor's reporting tab or reuse the existing one. Reporting tabs are
33
+ // appended at the end of the tab list so action-tab indices (including `activeTab`)
34
+ // never shift, and the active tab is left untouched.
35
+ export function openMonitorTab(managers, name, dotColor) {
36
+ const existing = monitorTabs(managers).find((t) => t.label === name);
37
+ if (existing)
38
+ return existing;
39
+ const tabs = managers.tab.tabs;
40
+ const tab = makeMonitorTab(name, dotColor, tabs.length + 1);
41
+ managers.tab.tabs = [...tabs, tab];
42
+ messageBus.emit('state', { type: 'dirty' });
43
+ return tab;
44
+ }
45
+ // Append a suggestion to the named monitor's feed (opening its tab if needed).
46
+ export function pushSuggestion(managers, name, dotColor, suggestion) {
47
+ const tab = openMonitorTab(managers, name, dotColor);
48
+ tab.monitor.suggestions.push(suggestion);
49
+ messageBus.emit('state', { type: 'dirty' });
50
+ }
51
+ // Close a persona's reporting tab (used when the last monitor feeding it goes away
52
+ // with its owning agent tab).
53
+ export function closeMonitorTab(managers, name) {
54
+ const index = managers.tab.tabs.findIndex((t) => t.view === 'monitor' && t.label === name);
55
+ if (index !== -1)
56
+ managers.tab.closeTab(index);
57
+ }
58
+ // Find a suggestion anywhere in the monitor feeds by id.
59
+ export function findSuggestion(managers, id) {
60
+ return monitorTabs(managers)
61
+ .flatMap((t) => t.monitor.suggestions)
62
+ .find((s) => s.id === id);
63
+ }
64
+ // Remove a suggestion from whichever feed holds it (thumbs-down).
65
+ export function removeSuggestion(managers, id) {
66
+ for (const tab of monitorTabs(managers)) {
67
+ const before = tab.monitor.suggestions.length;
68
+ tab.monitor.suggestions = tab.monitor.suggestions.filter((s) => s.id !== id);
69
+ if (tab.monitor.suggestions.length !== before)
70
+ messageBus.emit('state', { type: 'dirty' });
71
+ }
72
+ }
73
+ // Run a suggestion's command in the tab the suggestion is about (a clicked command
74
+ // line). The suggestion stays in the feed — the history of what was suggested (and run)
75
+ // is part of the record. Monitor tabs themselves never execute commands.
76
+ export function runSuggestion(managers, id) {
77
+ const suggestion = findSuggestion(managers, id);
78
+ if (!suggestion?.command)
79
+ return;
80
+ managers.command.dispatchTo(suggestion.about, suggestion.command);
81
+ }
@@ -0,0 +1,32 @@
1
+ // The notifications tab is a singleton, view-only feed (`view: 'notifications'`) that receives
2
+ // notification-worthy background events as ordinary transcript entries. It mirrors the file tree
3
+ // tab's open-or-reuse, dockable shape (see `file-tree-manager.ts`), but its body is a plain
4
+ // transcript and it accepts no typed input. It is created only by the `notifications` command,
5
+ // never by the event path, and events fired while it is closed are dropped (never buffered).
6
+ export const NOTIFICATIONS_LABEL = 'notifications';
7
+ // The open notifications tab, or undefined when none is open.
8
+ export function notificationsTab(managers) {
9
+ return managers.tab.tabs.find((t) => t.view === 'notifications');
10
+ }
11
+ // Open the notifications tab or reuse the existing one, optionally docking it into a sidebar.
12
+ // Called only from the `notifications` command. With no `dock`, an existing docked tab is undocked
13
+ // back to the center strip and made active (bare `notifications` always makes the feed visible).
14
+ export function openNotificationsTab(managers, dock) {
15
+ const existing = notificationsTab(managers);
16
+ if (existing) {
17
+ managers.tab.setDock(managers.tab.findIndex(existing.label), dock ?? null);
18
+ return existing;
19
+ }
20
+ managers.tab.openNotificationsTab();
21
+ if (dock)
22
+ managers.tab.setDock(managers.tab.findIndex(NOTIFICATIONS_LABEL), dock);
23
+ return notificationsTab(managers);
24
+ }
25
+ // Append a line to the notifications feed — but only if the tab is already open. When it is closed
26
+ // this is a no-op: the event is dropped, not buffered, and the tab is never created. When open it
27
+ // reuses the standard `append` funnel (unread badge, `entry:appended`, `bufferLines` sync).
28
+ export function appendNotification(managers, entry) {
29
+ if (!notificationsTab(managers))
30
+ return;
31
+ managers.tab.append(NOTIFICATIONS_LABEL, entry);
32
+ }
@@ -0,0 +1,87 @@
1
+ import { getConfig } from './config.js';
2
+ import { NOTIFICATIONS_LABEL, notificationsTab, appendNotification } from './notifications-tab.js';
3
+ // Whether an event should be recorded, given the config and the active tab. Defensive against the
4
+ // tab feeding itself. For the five ambient events, both the per-event opt-in toggle and focus
5
+ // suppression (the active tab never notifies about its own activity) apply; the `manual` event
6
+ // bypasses both — an explicit trigger always fires (subject only to the tab being open, enforced
7
+ // in `notify`).
8
+ export function shouldNotify(config, event, tabLabel, activeLabel) {
9
+ if (tabLabel === NOTIFICATIONS_LABEL)
10
+ return false;
11
+ if (event === 'manual' || event === 'auto-approve')
12
+ return true;
13
+ if (tabLabel === activeLabel)
14
+ return false;
15
+ if (!config)
16
+ return false;
17
+ switch (event) {
18
+ case 'state-change': {
19
+ return config.events.stateChange;
20
+ }
21
+ case 'incoming-message': {
22
+ return config.events.incomingMessage;
23
+ }
24
+ case 'schedule-fire': {
25
+ return config.events.scheduleFire;
26
+ }
27
+ case 'agent-start': {
28
+ return config.events.agentStart;
29
+ }
30
+ case 'rate-limited': {
31
+ return config.events.rateLimited;
32
+ }
33
+ }
34
+ }
35
+ // A compact 12-hour clock time (e.g. `8:32pm`) — hour without a leading zero, two-digit minutes,
36
+ // lowercase am/pm, no seconds. Leads each notification line's provenance header.
37
+ export function formatTimestamp(date) {
38
+ const minutes = String(date.getMinutes()).padStart(2, '0');
39
+ const period = date.getHours() < 12 ? 'am' : 'pm';
40
+ const hour12 = date.getHours() % 12 === 0 ? 12 : date.getHours() % 12;
41
+ return `${hour12}:${minutes}${period}`;
42
+ }
43
+ // The message body for an event, rendered after the `<time> <tabLabel>:` header. `detail` carries
44
+ // the event-specific extra: the command for `schedule-fire`, the sender label for
45
+ // `incoming-message`, the user's message for `manual`, and the approver's message for
46
+ // `auto-approve`. The `manual` and `auto-approve` bodies are the message alone — the tab label
47
+ // already leads the line via the header, so repeating it here would double it.
48
+ export function notificationText(event, tabLabel, detail) {
49
+ switch (event) {
50
+ case 'state-change': {
51
+ return `Agent '${tabLabel}' finished`;
52
+ }
53
+ case 'agent-start': {
54
+ return `Agent '${tabLabel}' started`;
55
+ }
56
+ case 'rate-limited': {
57
+ return `Agent '${tabLabel}' is being rate limited`;
58
+ }
59
+ case 'schedule-fire': {
60
+ return `Scheduled: ${detail} in ${tabLabel}`;
61
+ }
62
+ case 'incoming-message': {
63
+ return `Message from ${detail} in ${tabLabel}`;
64
+ }
65
+ case 'manual':
66
+ case 'auto-approve': {
67
+ return detail ?? '';
68
+ }
69
+ }
70
+ }
71
+ // Record a notification for an event on `tabLabel`. Returns immediately (costing nothing, and
72
+ // never creating the tab) while the notifications tab is closed, so the event path is free when the
73
+ // feed is not open. Otherwise it consults the config + focus rules via `shouldNotify` and, on pass,
74
+ // appends the derived line. `message` is the event-specific detail (see `notificationText`).
75
+ export function notify(managers, event, tabLabel, message) {
76
+ if (!notificationsTab(managers))
77
+ return;
78
+ const activeLabel = managers.tab.cur().label;
79
+ if (!shouldNotify(getConfig().notifications, event, tabLabel, activeLabel))
80
+ return;
81
+ const fromColor = managers.tab.tabs.find((t) => t.label === tabLabel)?.dotColor;
82
+ // The dot label is the notification's provenance header — when, then who — so the line reads
83
+ // `● 8:32pm janus: <message>`. `fromColor` (looked up from tabLabel) still colors the dot.
84
+ const from = `${formatTimestamp(new Date())} ${tabLabel}`;
85
+ const output = notificationText(event, tabLabel, message);
86
+ appendNotification(managers, { input: '', output, from, fromColor });
87
+ }