lazyclaw 6.3.0 → 6.4.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 (165) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +17 -13
  3. package/agents.mjs +54 -4
  4. package/channels-discord/index.mjs +5 -1
  5. package/channels-email/index.mjs +4 -3
  6. package/channels-whatsapp/index.mjs +3 -2
  7. package/chat_window.mjs +11 -2
  8. package/cli.mjs +103 -3
  9. package/commands/agents.mjs +6 -1
  10. package/commands/auth_nodes.mjs +19 -1
  11. package/commands/automation.mjs +20 -5
  12. package/commands/channels.mjs +46 -3
  13. package/commands/chat.mjs +95 -37
  14. package/commands/chat_hardening.mjs +23 -0
  15. package/commands/daemon.mjs +99 -1
  16. package/commands/gateway.mjs +123 -4
  17. package/commands/mcp.mjs +150 -0
  18. package/commands/misc.mjs +20 -0
  19. package/commands/sessions.mjs +68 -8
  20. package/commands/setup.mjs +44 -8
  21. package/commands/setup_channels.mjs +219 -47
  22. package/commands/skills.mjs +19 -1
  23. package/commands/workflow_named.mjs +137 -0
  24. package/config-validate.mjs +10 -1
  25. package/config_features.mjs +45 -0
  26. package/cron.mjs +19 -8
  27. package/daemon/lib/auth.mjs +25 -0
  28. package/daemon/lib/cost.mjs +36 -0
  29. package/daemon/lib/respond.mjs +20 -1
  30. package/daemon/lib/team_inbound.mjs +69 -0
  31. package/daemon/route_table.mjs +5 -1
  32. package/daemon/routes/_deps.mjs +2 -2
  33. package/daemon/routes/config.mjs +11 -6
  34. package/daemon/routes/conversation.mjs +103 -40
  35. package/daemon/routes/events.mjs +45 -0
  36. package/daemon/routes/meta.mjs +38 -7
  37. package/daemon/routes/providers.mjs +26 -6
  38. package/daemon/routes/workflows.mjs +30 -0
  39. package/daemon.mjs +26 -2
  40. package/dotenv_min.mjs +15 -3
  41. package/gateway/challenge_registry.mjs +90 -0
  42. package/gateway/device_auth.mjs +71 -97
  43. package/gateway/http_gateway.mjs +29 -3
  44. package/lib/args.mjs +69 -3
  45. package/lib/config.mjs +85 -3
  46. package/lib/render.mjs +43 -0
  47. package/lib/service_install.mjs +15 -1
  48. package/mas/agent_turn.mjs +61 -12
  49. package/mas/confidence.mjs +20 -1
  50. package/mas/embedder.mjs +100 -0
  51. package/mas/events.mjs +57 -0
  52. package/mas/index_db.mjs +103 -10
  53. package/mas/learning.mjs +96 -72
  54. package/mas/mention_router.mjs +48 -90
  55. package/mas/prompt_stack.mjs +60 -3
  56. package/mas/recall_blend.mjs +56 -0
  57. package/mas/redact.mjs +55 -0
  58. package/mas/router_posting.mjs +96 -0
  59. package/mas/router_termination.mjs +63 -0
  60. package/mas/scrub_env.mjs +21 -6
  61. package/mas/skill_synth.mjs +7 -33
  62. package/mas/tool_runner.mjs +3 -2
  63. package/mas/tools/bash.mjs +9 -2
  64. package/mas/tools/coding.mjs +28 -5
  65. package/mas/tools/delegation.mjs +34 -4
  66. package/mas/tools/git.mjs +19 -9
  67. package/mas/tools/ha.mjs +2 -0
  68. package/mas/tools/learning.mjs +55 -0
  69. package/mas/tools/media.mjs +21 -3
  70. package/mas/tools/os.mjs +70 -16
  71. package/mas/tools/recall.mjs +28 -2
  72. package/mcp/client.mjs +8 -1
  73. package/mcp/server_spawn.mjs +5 -1
  74. package/memory.mjs +24 -0
  75. package/package.json +3 -1
  76. package/providers/anthropic.mjs +101 -6
  77. package/providers/cache.mjs +9 -1
  78. package/providers/claude_cli.mjs +104 -22
  79. package/providers/claude_cli_session.mjs +166 -0
  80. package/providers/cli_error.mjs +38 -0
  81. package/providers/cli_login.mjs +179 -0
  82. package/providers/codex_cli.mjs +66 -11
  83. package/providers/gemini.mjs +101 -3
  84. package/providers/gemini_cli.mjs +62 -15
  85. package/providers/model_catalogue.mjs +33 -2
  86. package/providers/ollama.mjs +104 -3
  87. package/providers/openai.mjs +110 -8
  88. package/providers/openai_compat.mjs +97 -6
  89. package/providers/orchestrator.mjs +112 -12
  90. package/providers/registry.mjs +15 -9
  91. package/providers/retry.mjs +14 -2
  92. package/providers/tool_use/anthropic.mjs +17 -3
  93. package/providers/tool_use/claude_cli.mjs +72 -20
  94. package/providers/tool_use/gemini.mjs +18 -2
  95. package/providers/tool_use/openai.mjs +28 -5
  96. package/sandbox/confiners/seatbelt.mjs +37 -12
  97. package/sandbox/index.mjs +49 -0
  98. package/sandbox/local.mjs +7 -1
  99. package/sandbox/spawn.mjs +144 -0
  100. package/sandbox.mjs +5 -1
  101. package/scripts/loop-worker.mjs +25 -1
  102. package/sessions.mjs +0 -0
  103. package/skills/channel-style.md +20 -0
  104. package/skills/code-review.md +33 -0
  105. package/skills/commit-message.md +30 -0
  106. package/skills/concise.md +24 -0
  107. package/skills/debug-coach.md +25 -0
  108. package/skills/explain.md +24 -0
  109. package/skills/korean.md +25 -0
  110. package/skills/summarize.md +33 -0
  111. package/skills.mjs +24 -2
  112. package/skills_curator.mjs +6 -0
  113. package/skills_install.mjs +10 -2
  114. package/tasks.mjs +6 -1
  115. package/teams.mjs +78 -0
  116. package/tui/chat_mode_slash.mjs +59 -0
  117. package/tui/config_picker.mjs +1 -0
  118. package/tui/editor.mjs +178 -151
  119. package/tui/editor_keys.mjs +275 -0
  120. package/tui/hud.mjs +111 -0
  121. package/tui/login_flow.mjs +113 -0
  122. package/tui/modal_picker.mjs +10 -1
  123. package/tui/model_pick.mjs +287 -0
  124. package/tui/orchestrator_flow.mjs +164 -0
  125. package/tui/pickers.mjs +210 -95
  126. package/tui/repl.mjs +106 -44
  127. package/tui/repl_reset.mjs +37 -0
  128. package/tui/run_turn.mjs +228 -26
  129. package/tui/slash_args.mjs +159 -0
  130. package/tui/slash_commands.mjs +7 -5
  131. package/tui/slash_dispatcher.mjs +537 -344
  132. package/tui/slash_popup.mjs +5 -1
  133. package/tui/splash.mjs +15 -2
  134. package/tui/status_bar.mjs +26 -0
  135. package/tui/theme.mjs +28 -0
  136. package/web/avatars/01.png +0 -0
  137. package/web/avatars/02.png +0 -0
  138. package/web/avatars/03.png +0 -0
  139. package/web/avatars/04.png +0 -0
  140. package/web/avatars/05.png +0 -0
  141. package/web/avatars/06.png +0 -0
  142. package/web/avatars/07.png +0 -0
  143. package/web/avatars/08.png +0 -0
  144. package/web/avatars/09.png +0 -0
  145. package/web/avatars/10.png +0 -0
  146. package/web/avatars/11.png +0 -0
  147. package/web/avatars/12.png +0 -0
  148. package/web/avatars/13.png +0 -0
  149. package/web/avatars/14.png +0 -0
  150. package/web/avatars/15.png +0 -0
  151. package/web/avatars/16.png +0 -0
  152. package/web/avatars/17.png +0 -0
  153. package/web/avatars/18.png +0 -0
  154. package/web/avatars/19.png +0 -0
  155. package/web/avatars/20.png +0 -0
  156. package/web/dashboard.css +77 -0
  157. package/web/dashboard.html +29 -2
  158. package/web/dashboard.js +296 -33
  159. package/workflow/builtin_caps.mjs +94 -0
  160. package/workflow/declarative.mjs +101 -0
  161. package/workflow/named.mjs +71 -0
  162. package/workflow/named_cron.mjs +50 -0
  163. package/workflow/nodes.mjs +67 -0
  164. package/workflow/run_request.mjs +74 -0
  165. package/workflow/yaml_min.mjs +97 -0
@@ -0,0 +1,144 @@
1
+ // sandbox/spawn.mjs — unified spawn dispatcher + auto-confiner.
2
+ //
3
+ // Bridges the docker-spec shim (used by mas/tools/bash.mjs and
4
+ // providers/{claude,codex,gemini}_cli.mjs via sandbox.mjs) with the v5
5
+ // confiner backend. Today only docker specs and null are handled by the
6
+ // docker.mjs spawnSandboxed; this module adds {kind:'local', confiner:...}
7
+ // support by wrapping the child argv via a confiner module.
8
+ //
9
+ // CAPABILITY-ONLY: no caller passes a local spec yet, so null and docker
10
+ // paths are byte-identical to docker.mjs's spawnSandboxed and there is zero
11
+ // behaviour change. The default-OFF posture is unchanged.
12
+
13
+ import { spawn, spawnSync } from 'node:child_process';
14
+ import { buildDockerArgs } from './docker.mjs';
15
+ import { SandboxError } from './base.mjs';
16
+ import * as seatbelt from './confiners/seatbelt.mjs';
17
+ import * as bubblewrap from './confiners/bubblewrap.mjs';
18
+ import * as firejail from './confiners/firejail.mjs';
19
+ import * as landlock from './confiners/landlock.mjs';
20
+
21
+ const CONFINERS = { seatbelt, bubblewrap, firejail, landlock };
22
+
23
+ // Process-wide memo of the real-probe result, keyed by platform. Auto-confiner
24
+ // selection sits on a hot path (every confined child spawn), so we avoid
25
+ // repeated execFileSync probes. The memo is bypassed when an explicit `avail`
26
+ // override is supplied (tests need a fresh evaluation per call).
27
+ const _probeMemo = new Map();
28
+
29
+ /**
30
+ * Pick the best available confiner for a platform.
31
+ *
32
+ * @param {{platform?: string, avail?: {seatbelt?: boolean, bubblewrap?: boolean, firejail?: boolean}}} [opts]
33
+ * @returns {'seatbelt'|'bubblewrap'|'firejail'|'none'}
34
+ * Never returns 'landlock' — it is fail-closed (available() === false).
35
+ */
36
+ export function pickAvailableConfiner({ platform = process.platform, avail } = {}) {
37
+ const probe = avail
38
+ ? () => avail
39
+ : () => _probeAvailability(platform);
40
+ const a = probe();
41
+ if (platform === 'darwin') {
42
+ return a.seatbelt ? 'seatbelt' : 'none';
43
+ }
44
+ if (platform === 'linux') {
45
+ if (a.bubblewrap) return 'bubblewrap';
46
+ if (a.firejail) return 'firejail';
47
+ return 'none';
48
+ }
49
+ return 'none';
50
+ }
51
+
52
+ function _probeAvailability(platform) {
53
+ if (_probeMemo.has(platform)) return _probeMemo.get(platform);
54
+ const a = {
55
+ seatbelt: seatbelt.available({ platform }),
56
+ bubblewrap: bubblewrap.available(),
57
+ firejail: firejail.available(),
58
+ };
59
+ _probeMemo.set(platform, a);
60
+ return a;
61
+ }
62
+
63
+ /**
64
+ * Build the WRAPPED argv for a {kind:'local'} spec WITHOUT spawning, so it is
65
+ * unit-testable.
66
+ *
67
+ * @param {{kind:'local', confiner?: string} & Record<string, unknown>} spec
68
+ * @param {string} bin
69
+ * @param {string[]} args
70
+ * @returns {string[]} the wrapped argv ([bin, ...args] when confiner is none)
71
+ */
72
+ export function buildLocalArgv(spec, bin, args) {
73
+ const requested = spec.confiner === 'auto'
74
+ ? pickAvailableConfiner()
75
+ : (spec.confiner || 'none');
76
+ if (requested === 'none') return [bin, ...args];
77
+ const mod = CONFINERS[requested];
78
+ if (!mod) {
79
+ throw new SandboxError(`unknown confiner "${requested}"`, 'SANDBOX_BAD_CONFINER');
80
+ }
81
+ // landlock is fail-closed: its buildArgv throws. Do NOT special-case it.
82
+ return mod.buildArgv([bin, ...args], spec);
83
+ }
84
+
85
+ /**
86
+ * Spawn a child under the requested sandbox spec.
87
+ *
88
+ * - null → bare spawn (byte-identical to docker.mjs today)
89
+ * - kind 'docker' → spawn('docker', buildDockerArgs(...)) (byte-identical)
90
+ * - kind 'local' → wrap argv via confiner, then spawn the wrapper
91
+ * - anything else → SANDBOX_UNSUPPORTED
92
+ *
93
+ * @param {object|null} spec
94
+ * @param {string} bin
95
+ * @param {string[]} args
96
+ * @param {object} [spawnOpts]
97
+ * @returns {import('node:child_process').ChildProcess}
98
+ */
99
+ export function spawnSandboxed(spec, bin, args, spawnOpts = {}) {
100
+ if (!spec) return spawn(bin, args, spawnOpts);
101
+ if (spec.kind === 'docker') {
102
+ const dockerArgs = buildDockerArgs(spec, [bin, ...args], { cwd: spawnOpts.cwd });
103
+ return spawn('docker', dockerArgs, spawnOpts);
104
+ }
105
+ if (spec.kind === 'local') {
106
+ const wrapped = buildLocalArgv(spec, bin, args);
107
+ return spawn(wrapped[0], wrapped.slice(1), spawnOpts);
108
+ }
109
+ throw new SandboxError(
110
+ `spawnSandboxed shim handles docker+local only; got "${spec.kind}"`,
111
+ 'SANDBOX_UNSUPPORTED',
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Synchronous mirror of {@link spawnSandboxed} for callers that shell out via
117
+ * spawnSync (e.g. mas/tools/git.mjs). Same kind handling, just sync:
118
+ *
119
+ * - null → bare spawnSync (byte-identical to a plain spawnSync)
120
+ * - kind 'docker' → spawnSync('docker', buildDockerArgs(...)) (byte-identical)
121
+ * - kind 'local' → wrap argv via confiner, then spawnSync the wrapper
122
+ * - anything else → SANDBOX_UNSUPPORTED
123
+ *
124
+ * @param {object|null} spec
125
+ * @param {string} bin
126
+ * @param {string[]} args
127
+ * @param {object} [spawnOpts]
128
+ * @returns {import('node:child_process').SpawnSyncReturns<string>}
129
+ */
130
+ export function spawnSyncSandboxed(spec, bin, args, spawnOpts = {}) {
131
+ if (!spec) return spawnSync(bin, args, spawnOpts);
132
+ if (spec.kind === 'docker') {
133
+ const dockerArgs = buildDockerArgs(spec, [bin, ...args], { cwd: spawnOpts.cwd });
134
+ return spawnSync('docker', dockerArgs, spawnOpts);
135
+ }
136
+ if (spec.kind === 'local') {
137
+ const wrapped = buildLocalArgv(spec, bin, args);
138
+ return spawnSync(wrapped[0], wrapped.slice(1), spawnOpts);
139
+ }
140
+ throw new SandboxError(
141
+ `spawnSyncSandboxed shim handles docker+local only; got "${spec.kind}"`,
142
+ 'SANDBOX_UNSUPPORTED',
143
+ );
144
+ }
package/sandbox.mjs CHANGED
@@ -6,6 +6,10 @@
6
6
  export {
7
7
  parseSandboxSpec,
8
8
  buildDockerArgs,
9
- spawnSandboxed,
10
9
  } from './sandbox/docker.mjs';
10
+ // spawnSandboxed now comes from the unified dispatcher, which adds
11
+ // {kind:'local'} confiner support on top of the byte-identical null/docker
12
+ // paths. bash.mjs + the 3 CLI providers import it via this shim, so they
13
+ // transparently get the upgraded dispatcher.
14
+ export { spawnSandboxed, spawnSyncSandboxed } from './sandbox/spawn.mjs';
11
15
  export { SandboxError } from './sandbox/base.mjs';
@@ -64,10 +64,15 @@ const sessions = await import(path.join(REPO_ROOT, 'sessions.mjs'));
64
64
  const loopEngine = await import(path.join(REPO_ROOT, 'loop-engine.mjs'));
65
65
  const registryUrl = path.join(REPO_ROOT, 'providers', 'registry.mjs');
66
66
  const { PROVIDERS } = await import(registryUrl);
67
+ const { readConfig, _resolveAuthKey } = await import(path.join(REPO_ROOT, 'lib', 'config.mjs'));
67
68
 
68
69
  const cfgDir = process.env.LAZYCLAW_CONFIG_DIR || loops.defaultConfigDir();
69
70
  const sessionId = args['session-existing'] || `loop:${loopId}`;
70
71
 
72
+ // Resolve the auth key the SAME way the foreground loop does — the worker used
73
+ // process.env.LAZYCLAW_API_KEY||'' and so failed auth against anthropic/openai.
74
+ const cfg = readConfig();
75
+
71
76
  const provName = args.provider || 'mock';
72
77
  const prov = PROVIDERS[provName];
73
78
  if (!prov) {
@@ -111,7 +116,7 @@ async function sendOnce(messages, signal) {
111
116
  }
112
117
  let acc = '';
113
118
  for await (const chunk of prov.sendMessage(messages, {
114
- apiKey: process.env.LAZYCLAW_API_KEY || '',
119
+ apiKey: _resolveAuthKey(cfg, provName),
115
120
  model: args.model,
116
121
  signal,
117
122
  })) {
@@ -144,6 +149,24 @@ const onIteration = ({ i, max: m, reply }) => {
144
149
  }, cfgDir);
145
150
  };
146
151
 
152
+ // Honour --use-memory / --recall exactly like the foreground loop: rebuild a
153
+ // system message from core/recall memory before each iteration. The detach
154
+ // path forwards the flags now (buildDetachArgv); without this the worker would
155
+ // see them and still ignore them.
156
+ const memMod = (args['use-memory'] || args.recall) ? await import(path.join(REPO_ROOT, 'memory.mjs')) : null;
157
+ const buildSystem = memMod ? (() => {
158
+ const parts = [];
159
+ if (args['use-memory']) {
160
+ const core = memMod.loadCore(cfgDir);
161
+ if (core && core.trim()) parts.push(core);
162
+ }
163
+ if (args.recall) {
164
+ const text = memMod.recall(String(args.recall), { topN: 3 }, cfgDir);
165
+ if (text && text.trim()) parts.push(text);
166
+ }
167
+ return parts.join('\n\n---\n\n');
168
+ }) : null;
169
+
147
170
  try {
148
171
  const result = await loopEngine.runLoop({
149
172
  prompt: args.prompt || '',
@@ -154,6 +177,7 @@ try {
154
177
  persist,
155
178
  onIteration,
156
179
  signal: ac.signal,
180
+ buildSystem,
157
181
  });
158
182
  if (!terminating) {
159
183
  const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
package/sessions.mjs CHANGED
Binary file
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Format replies for messaging channels (Slack, Telegram, Discord). Short messages, no heavy markdown, mobile-readable.
3
+ created_by: starter
4
+ version: 1
5
+ ---
6
+
7
+ # Channel-friendly formatting
8
+
9
+ Your reply will be read in a chat app (Slack, Telegram, Discord, Matrix), often on a phone. Format for that surface, not for a terminal or a wiki.
10
+
11
+ ## Rules
12
+
13
+ - Keep messages short: lead with the answer, aim under ~120 words. If the topic genuinely needs more, give the short answer first and offer to expand.
14
+ - No `#` headings and no horizontal rules — most chat apps render them as literal text or oversized noise. Use **bold** for the one key phrase instead.
15
+ - Bullets: at most one level deep, 5-6 items max. Numbered lists only for true sequences.
16
+ - Code: inline backticks for identifiers, fenced blocks only for runnable snippets ≤15 lines. Longer code → summarize what it does and offer the full version on request.
17
+ - No tables — they collapse on mobile clients. Use `label: value` lines instead.
18
+ - Links: bare URL or `<url|text>`-style only if the channel supports it; never markdown `[text](url)` on Slack.
19
+ - One question per message when you need input — multi-part questionnaires get half-answered.
20
+ - Emoji: at most one, and only when it carries tone the words don't.
@@ -0,0 +1,33 @@
1
+ ---
2
+ description: Structured code review. One line per finding with severity and a concrete fix; no praise, no nitpicks that don't change meaning.
3
+ created_by: starter
4
+ version: 1
5
+ ---
6
+
7
+ # Code review
8
+
9
+ Review the code or diff the user provides. Output findings, not commentary.
10
+
11
+ ## Output format
12
+
13
+ One line per finding:
14
+
15
+ ```
16
+ <file>:<line> <SEVERITY>: <problem>. <concrete fix>.
17
+ ```
18
+
19
+ Severities, in the order findings should appear:
20
+
21
+ - **BLOCKER** — bugs, data loss, security holes, broken builds. Must fix before merge.
22
+ - **MAJOR** — correctness risks, missing error handling, race conditions, leaks.
23
+ - **MINOR** — readability, naming, dead code, missed simplification.
24
+
25
+ End with a one-line verdict: `verdict: <ship | fix blockers first | needs rework> (<n> findings)`.
26
+
27
+ ## Rules
28
+
29
+ - Every finding names a location and a fix. "This could be better" is not a finding.
30
+ - Check, in priority order: correctness → security (injection, path traversal, secrets in code/logs) → error handling (swallowed exceptions, missing timeouts) → concurrency → resource cleanup → API misuse → readability.
31
+ - Skip pure formatting nits unless they change meaning — a formatter owns those.
32
+ - No praise padding. If the code is fine, say `verdict: ship (0 findings)` and stop.
33
+ - If you can't see enough context to judge a line, say what file/definition you'd need — don't guess.
@@ -0,0 +1,30 @@
1
+ ---
2
+ description: Write Conventional Commits messages from a diff or change description. Subject ≤50 chars, body explains why.
3
+ created_by: starter
4
+ version: 1
5
+ ---
6
+
7
+ # Commit message writer
8
+
9
+ Given a diff, a `git status`, or a description of a change, produce a commit message in Conventional Commits format.
10
+
11
+ ## Format
12
+
13
+ ```
14
+ <type>(<scope>): <subject>
15
+
16
+ <body — only when the why isn't obvious from the subject>
17
+ ```
18
+
19
+ - `type`: one of `feat` `fix` `docs` `refactor` `test` `chore` `perf` `style` `build` `ci`.
20
+ - `scope`: the subsystem touched (directory or module name), omit if the change is global.
21
+ - `subject`: imperative mood ("add", not "added"/"adds"), no trailing period, ≤50 characters.
22
+ - `body`: wrap at 72 chars. Explain **why** the change was made and any non-obvious consequence. Never narrate the diff line by line — the diff already shows the what.
23
+
24
+ ## Rules
25
+
26
+ - One logical change per message. If the diff mixes unrelated changes, say so and propose a split instead of writing one blurry message.
27
+ - Breaking change → add a `BREAKING CHANGE:` footer describing the migration.
28
+ - Reference issues in the footer (`Fixes #123`), not the subject.
29
+ - No emoji, no marketing language, no "various fixes".
30
+ - Output the message in a code block, ready to paste — nothing else unless asked.
@@ -0,0 +1,24 @@
1
+ ---
2
+ description: Terse, high-signal replies. Cut filler and hedging, keep every technical fact. Good default for chat and channels.
3
+ created_by: starter
4
+ version: 1
5
+ ---
6
+
7
+ # Concise replies
8
+
9
+ Answer with maximum signal per token. The reader is busy; respect that.
10
+
11
+ ## Rules
12
+
13
+ - Lead with the answer or outcome. Context and caveats come after, only if they change what the reader does next.
14
+ - Drop filler ("basically", "just", "as you can see"), pleasantries, and restatements of the question.
15
+ - No hedging chains. One qualifier max, and only when uncertainty is real — then say what would resolve it.
16
+ - Prefer a short sentence over a bullet, a bullet over a paragraph, a table only for genuinely tabular data.
17
+ - Code, commands, errors, and identifiers stay exact and complete — never compress those.
18
+ - If the honest answer is "I don't know", say it in one line and state what's needed to find out.
19
+
20
+ ## Length budget
21
+
22
+ - Simple question → 1-3 sentences.
23
+ - How-to → numbered steps, one line each.
24
+ - Anything longer than ~150 words: stop and ask yourself which half the reader can live without.
@@ -0,0 +1,25 @@
1
+ ---
2
+ description: Systematic debugging discipline — reproduce, isolate, hypothesize, verify. No guess-and-patch fixes.
3
+ created_by: starter
4
+ version: 1
5
+ ---
6
+
7
+ # Debug coach
8
+
9
+ When the user reports a bug, error, or unexpected behavior, work the problem systematically instead of pattern-matching to the first plausible fix.
10
+
11
+ ## Method
12
+
13
+ 1. **Reproduce** — pin down the exact failing input, command, and error text. If it can't be reproduced, that's the first problem to solve.
14
+ 2. **Read the error** — the actual message, the actual stack frame, the actual line. Quote it back exactly; most bugs are solved here.
15
+ 3. **Isolate** — bisect: which half of the pipeline is the failure in? Repeat until the broken unit is small enough to reason about. Recent changes (`git diff`, `git log`) are prime suspects.
16
+ 4. **Hypothesize, then verify** — state one specific cause and the observation that would confirm or kill it (a log line, a debugger value, a minimal repro). Check it before touching code.
17
+ 5. **Fix the cause, not the symptom** — a `try/catch` around the crash site or a sleep before the race is a symptom patch. Say so explicitly if a workaround is all that's feasible now.
18
+ 6. **Prove it** — re-run the original failing case, and add a regression test when there's a test suite to put it in.
19
+
20
+ ## Rules
21
+
22
+ - Never claim "fixed" without re-running the failing case and showing the result.
23
+ - One hypothesis at a time. Shotgunning five changes at once destroys the evidence.
24
+ - If two fixes in a row haven't worked, stop and question the diagnosis — the bug is upstream of where you're looking.
25
+ - Ask for the full error output and environment (versions, OS, config) when they're missing; don't reconstruct them from imagination.
@@ -0,0 +1,24 @@
1
+ ---
2
+ description: Explain code or concepts clearly — start from what it does, then how, calibrated to the reader's level.
3
+ created_by: starter
4
+ version: 1
5
+ ---
6
+
7
+ # Explainer
8
+
9
+ When asked to explain code, an error, a concept, or a design, teach it — don't just describe it.
10
+
11
+ ## Method
12
+
13
+ 1. **What it does, in one sentence** — purpose before mechanics. "This debounces the search input" beats a line-by-line tour.
14
+ 2. **How it works** — walk the key path in execution order. Name the 2-3 load-bearing pieces and skip the boilerplate.
15
+ 3. **Why it's built this way** — the constraint or trade-off that explains the non-obvious parts. If a simpler way exists, mention it.
16
+ 4. **Where it bites** — the gotcha a newcomer hits first (edge case, footgun, common misuse), if there is one.
17
+
18
+ ## Rules
19
+
20
+ - Calibrate to the reader: if their question shows expertise, skip fundamentals; if it doesn't, define terms on first use with a concrete example.
21
+ - Use a runnable mini-example over an abstract description when one fits in ≤10 lines.
22
+ - Analogies are seasoning, not the meal — one good one max, then back to the real thing.
23
+ - Don't explain what wasn't asked. A question about one function is not an invitation to tour the codebase.
24
+ - If the honest explanation is "this code is confusing because it's doing two unrelated things", say that.
@@ -0,0 +1,25 @@
1
+ ---
2
+ description: Respond in Korean; keep code, commands, logs, and identifiers in English. 한국어 응답, 기술 용어는 원문 유지.
3
+ created_by: starter
4
+ version: 1
5
+ ---
6
+
7
+ # Korean responses (한국어 응답)
8
+
9
+ Respond in Korean. The user reads Korean fastest, but works in an English-language technical stack.
10
+
11
+ ## Rules
12
+
13
+ - 답변 본문은 한국어로 작성한다.
14
+ - Code, shell commands, file paths, error messages, variable/function names, API fields: keep in English, verbatim. Never translate an error message — the user needs to grep for it.
15
+ - Established technical terms stay in English when the English term is what practitioners actually say (e.g. "race condition", "merge conflict"). Don't force awkward translations.
16
+ - 존댓말(해요체) 기본. 과도한 격식이나 번역투("~하는 것이 가능합니다")는 피하고 자연스러운 한국어로.
17
+ - Code comments and commit messages: English, unless the user asks otherwise.
18
+
19
+ ## Example
20
+
21
+ > `useEffect` 의존성 배열에 `user.id`가 빠져서 stale closure가 생깁니다. 아래처럼 고치세요:
22
+ >
23
+ > ```js
24
+ > useEffect(() => { fetchProfile(user.id); }, [user.id]);
25
+ > ```
@@ -0,0 +1,33 @@
1
+ ---
2
+ description: Digest long content (threads, docs, logs, transcripts) into TL;DR, key points, and action items with owners.
3
+ created_by: starter
4
+ version: 1
5
+ ---
6
+
7
+ # Summarizer
8
+
9
+ Turn long input — a thread, document, meeting transcript, log dump, or article — into a digest the reader can act on in under a minute.
10
+
11
+ ## Output shape
12
+
13
+ ```
14
+ TL;DR: <one sentence — the single thing to know>
15
+
16
+ Key points:
17
+ - <3-7 bullets, each a standalone fact or decision>
18
+
19
+ Action items: (only if the input contains or implies any)
20
+ - <who> — <what> — <by when, if stated>
21
+
22
+ Open questions: (only if real unresolved items exist)
23
+ - <question>
24
+ ```
25
+
26
+ ## Rules
27
+
28
+ - The TL;DR is the conclusion, not the topic. "Deploy is blocked on the cert renewal" — not "This thread discusses deployment."
29
+ - Preserve decisions, numbers, dates, names, and commitments exactly. These are the facts people come back to verify.
30
+ - Attribute contested claims ("X argues…, Y counters…") instead of flattening disagreement into false consensus.
31
+ - Drop greetings, repetition, and back-and-forth that didn't change the outcome.
32
+ - If the input is too truncated or ambiguous to summarize faithfully, say what's missing instead of papering over it.
33
+ - Match the input's language (Korean input → Korean summary), keeping technical terms as-is.
package/skills.mjs CHANGED
@@ -27,6 +27,13 @@ const SKILL_EXT = '.md';
27
27
  // that want to be explicit.
28
28
  const _indexCache = new Map(); // cfgDir → { mtimeMs, skills, index }
29
29
 
30
+ // Per-skill BODY cache, keyed by `${cfgDir}::${name}` → { mtimeMs, body }.
31
+ // composeSystemPrompt calls loadSkill once per requested skill on every
32
+ // POST /agent reply, so an unchanged skill .md used to be re-read from disk
33
+ // each turn. Keyed by file mtime so a live edit (or installSkill rewrite)
34
+ // busts the entry; install/removeSkill also clear it explicitly.
35
+ const _bodyCache = new Map();
36
+
30
37
  export function _invalidateSkillsCache(configDir = defaultConfigDir()) {
31
38
  _indexCache.delete(configDir);
32
39
  }
@@ -152,8 +159,15 @@ export function skillsIndex(configDir = defaultConfigDir()) {
152
159
 
153
160
  export function loadSkill(name, configDir = defaultConfigDir()) {
154
161
  const p = skillPath(name, configDir);
155
- if (!fs.existsSync(p)) throw new Error(`skill not found: ${name}`);
156
- return fs.readFileSync(p, 'utf8');
162
+ let mtimeMs;
163
+ try { mtimeMs = fs.statSync(p).mtimeMs; }
164
+ catch { throw new Error(`skill not found: ${name}`); }
165
+ const key = `${configDir}::${name}`;
166
+ const hit = _bodyCache.get(key);
167
+ if (hit && hit.mtimeMs === mtimeMs) return hit.body;
168
+ const body = fs.readFileSync(p, 'utf8');
169
+ _bodyCache.set(key, { mtimeMs, body });
170
+ return body;
157
171
  }
158
172
 
159
173
  export function installSkill(name, content, configDir = defaultConfigDir()) {
@@ -161,6 +175,7 @@ export function installSkill(name, content, configDir = defaultConfigDir()) {
161
175
  fs.mkdirSync(path.dirname(p), { recursive: true });
162
176
  fs.writeFileSync(p, content);
163
177
  _invalidateSkillsCache(configDir);
178
+ _bodyCache.delete(`${configDir}::${name}`);
164
179
  return p;
165
180
  }
166
181
 
@@ -168,6 +183,13 @@ export function removeSkill(name, configDir = defaultConfigDir()) {
168
183
  const p = skillPath(name, configDir);
169
184
  if (fs.existsSync(p)) fs.unlinkSync(p);
170
185
  _invalidateSkillsCache(configDir);
186
+ _bodyCache.delete(`${configDir}::${name}`);
187
+ // Drop the skill's FTS row too — unlinking the .md left a stale, still
188
+ // recallable index row. Lazy + best-effort so better-sqlite3 stays off the
189
+ // skills hot path and a delete hiccup never throws out of removeSkill.
190
+ import('./mas/index_db.mjs')
191
+ .then((idx) => { try { idx.deleteSkill(name, configDir); } catch { /* best-effort */ } })
192
+ .catch(() => { /* index unavailable */ });
171
193
  }
172
194
 
173
195
  export function skillExists(name, configDir = defaultConfigDir()) {
@@ -287,6 +287,12 @@ function moveToArchive(name, configDir, archivedAtMs) {
287
287
  throw err;
288
288
  }
289
289
  }
290
+ // Archiving must also drop the skill's FTS row, or a 90-day-idle skill stays
291
+ // recallable forever. Lazy + best-effort (keeps better-sqlite3 off the curate
292
+ // hot path; an index hiccup never fails the archival move).
293
+ import('./mas/index_db.mjs')
294
+ .then((idx) => { try { idx.deleteSkill(name, configDir); } catch { /* best-effort */ } })
295
+ .catch(() => { /* index unavailable */ });
290
296
  return dest;
291
297
  }
292
298
 
@@ -28,6 +28,7 @@ import os from 'node:os';
28
28
  import path from 'node:path';
29
29
  import { spawn } from 'node:child_process';
30
30
  import { Readable } from 'node:stream';
31
+ import { sanitizeSkillBody, neutralizeRoleLabels } from './mas/redact.mjs';
31
32
 
32
33
  const GITHUB_SPEC = /^([\w.-]+)\/([\w.-]+)(?:@([^:]+))?(?::(.+))?$/;
33
34
  const SKILL_EXT = '.md';
@@ -213,8 +214,15 @@ export function installPickedSkills(picked, configDir, opts = {}) {
213
214
  skipped.push({ name, reason: 'exists', dst });
214
215
  continue;
215
216
  }
216
- fs.copyFileSync(f.abs, dst);
217
- installed.push({ name, src: f.relative, dst, bytes: fs.statSync(dst).size });
217
+ // A remote skill body is injected into other agents' system prompts, so
218
+ // sanitize it on the way in — same pass the synth path uses (redact
219
+ // secrets, defang the [[TASK_DONE]] router marker, strip control chars,
220
+ // cap size) PLUS neutralize forged role labels ([System]/[User]) since
221
+ // remote content is fully untrusted. No code is executed either way.
222
+ const raw = fs.readFileSync(f.abs, 'utf8');
223
+ const clean = sanitizeSkillBody(neutralizeRoleLabels(raw));
224
+ fs.writeFileSync(dst, clean);
225
+ installed.push({ name, src: f.relative, dst, bytes: Buffer.byteLength(clean, 'utf8'), sanitized: clean !== raw });
218
226
  }
219
227
  return { installed, skipped };
220
228
  }
package/tasks.mjs CHANGED
@@ -15,7 +15,12 @@ import crypto from 'node:crypto';
15
15
  import { getTeam } from './teams.mjs';
16
16
 
17
17
  const TASKS_DIRNAME = 'tasks';
18
- export const VALID_STATUSES = ['pending', 'running', 'done', 'failed', 'abandoned'];
18
+ // 'paused' is a resumable terminal state: a router turn that stopped on the
19
+ // turn budget or went idle (no agent had more to do) lands here — it didn't
20
+ // fail, and `task tick <id>` resumes it (the router flips it back to 'running'
21
+ // for the next turn). Distinct from 'failed' (error), 'abandoned' (explicit
22
+ // abort), and 'done' (completed).
23
+ export const VALID_STATUSES = ['pending', 'running', 'done', 'failed', 'abandoned', 'paused'];
19
24
 
20
25
  export class TaskError extends Error {
21
26
  constructor(message, code) {
package/teams.mjs CHANGED
@@ -98,6 +98,7 @@ export function registerTeam({ name, displayName, agents = [], lead = null, slac
98
98
  slackChannel: String(slackChannel || ''),
99
99
  };
100
100
  writeAtomic(p, data);
101
+ _invalidateTeamIndex(configDir);
101
102
  return data;
102
103
  }
103
104
 
@@ -133,6 +134,7 @@ export function patchTeam(name, patch, configDir = defaultConfigDir()) {
133
134
  if (patch.agents !== undefined) next.agents = [...new Set(patch.agents)];
134
135
  validateAgentRefs(next.agents, next.lead, configDir);
135
136
  writeAtomic(teamPath(name, configDir), next);
137
+ _invalidateTeamIndex(configDir);
136
138
  return next;
137
139
  }
138
140
 
@@ -142,6 +144,7 @@ export function removeTeam(name, configDir = defaultConfigDir()) {
142
144
  throw new TeamError(`no team "${name}"`, 'TEAM_NO_TEAM');
143
145
  }
144
146
  fs.unlinkSync(p);
147
+ _invalidateTeamIndex(configDir);
145
148
  return { name, removed: true };
146
149
  }
147
150
 
@@ -197,3 +200,78 @@ export function parseListFlag(raw) {
197
200
  if (s === '') return [];
198
201
  return s.split(',').map(x => x.trim()).filter(Boolean);
199
202
  }
203
+
204
+ // Build the team's org tree for the dashboard: a nested { agent, children[] }
205
+ // rooted at team.lead. A member nests under its `manager` when that manager is
206
+ // also a team member; otherwise (no manager, or a manager outside the team) it
207
+ // attaches directly under the lead, so the result is always a single tree.
208
+ //
209
+ // @param {{lead:string, agents:string[]}} team
210
+ // @param {Record<string, object>} agentsById name → agent record
211
+ export function teamTree(team, agentsById = {}) {
212
+ const lead = team && team.lead;
213
+ if (!lead) return null;
214
+ const memberNames = new Set((team.agents || []));
215
+ const byManager = new Map(); // manager name → child names
216
+ for (const n of memberNames) {
217
+ if (n === lead) continue;
218
+ const rec = agentsById[n];
219
+ const mgr = rec && rec.manager && memberNames.has(rec.manager) && rec.manager !== n
220
+ ? rec.manager
221
+ : lead;
222
+ if (!byManager.has(mgr)) byManager.set(mgr, []);
223
+ byManager.get(mgr).push(n);
224
+ }
225
+ const build = (name, seen) => {
226
+ if (seen.has(name)) return null; // cycle guard (defensive — register/patch reject cycles)
227
+ const next = new Set(seen).add(name);
228
+ const node = { agent: agentsById[name] || { name }, children: [] };
229
+ for (const child of (byManager.get(name) || []).sort()) {
230
+ const c = build(child, next);
231
+ if (c) node.children.push(c);
232
+ }
233
+ return node;
234
+ };
235
+ return build(lead, new Set());
236
+ }
237
+
238
+ // Find the team bound to an inbound channel id (team.slackChannel), or null.
239
+ // Used by the daemon to auto-route a Slack message to a team's multi-agent loop.
240
+ export function teamForChannel(teams, channel) {
241
+ if (!channel) return null;
242
+ const c = String(channel);
243
+ return (teams || []).find((t) => t && t.slackChannel && String(t.slackChannel) === c) || null;
244
+ }
245
+
246
+ // slackChannel→team index, keyed by configDir and the teams/ dir mtime. Every
247
+ // inbound Slack message used to re-scan the whole teams/ directory (readdir + N
248
+ // JSON.parse) just to find one channel's team; this builds the lookup once and
249
+ // reuses it until the directory changes. register/patch/removeTeam invalidate
250
+ // explicitly (deterministic), and the dir-mtime key also catches manual edits.
251
+ const _channelIndex = new Map(); // configDir → { mtimeMs, byChannel: Map<channel, team> }
252
+
253
+ export function _invalidateTeamIndex(configDir = defaultConfigDir()) {
254
+ _channelIndex.delete(configDir);
255
+ }
256
+
257
+ // O(1) channel→team lookup for the inbound hot path. Returns null when no team
258
+ // is bound to the channel (or there is no teams/ directory yet).
259
+ export function teamForChannelCached(channel, configDir = defaultConfigDir()) {
260
+ if (!channel) return null;
261
+ const dir = teamsDir(configDir);
262
+ let mtimeMs = 0;
263
+ try { mtimeMs = fs.statSync(dir).mtimeMs; }
264
+ catch { return null; } // no teams dir → no team bound to anything
265
+ const cached = _channelIndex.get(configDir);
266
+ let byChannel;
267
+ if (cached && cached.mtimeMs === mtimeMs) {
268
+ byChannel = cached.byChannel;
269
+ } else {
270
+ byChannel = new Map();
271
+ for (const t of listTeams(configDir)) {
272
+ if (t && t.slackChannel) byChannel.set(String(t.slackChannel), t);
273
+ }
274
+ _channelIndex.set(configDir, { mtimeMs, byChannel });
275
+ }
276
+ return byChannel.get(String(channel)) || null;
277
+ }