teamshare-bridge 0.1.0 → 0.2.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 (60) hide show
  1. package/dist/bridge/daemon.js +239 -8
  2. package/dist/bridge/daemon.js.map +1 -1
  3. package/dist/bridge/index.js +10 -0
  4. package/dist/bridge/index.js.map +1 -1
  5. package/dist/bridge/local-server.d.ts +6 -1
  6. package/dist/bridge/local-server.js +24 -0
  7. package/dist/bridge/local-server.js.map +1 -1
  8. package/dist/bridge/protocol.js +33 -4
  9. package/dist/bridge/protocol.js.map +1 -1
  10. package/dist/bridge/spawn.d.ts +20 -4
  11. package/dist/bridge/spawn.js +93 -29
  12. package/dist/bridge/spawn.js.map +1 -1
  13. package/dist/cli/index.js +1102 -64
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/lib/api.d.ts +203 -5
  16. package/dist/lib/api.js +144 -3
  17. package/dist/lib/api.js.map +1 -1
  18. package/dist/lib/chat-reply.d.ts +22 -2
  19. package/dist/lib/chat-reply.js +139 -67
  20. package/dist/lib/chat-reply.js.map +1 -1
  21. package/dist/lib/config.d.ts +103 -0
  22. package/dist/lib/config.js +272 -0
  23. package/dist/lib/config.js.map +1 -1
  24. package/dist/lib/doc-reply.d.ts +11 -0
  25. package/dist/lib/doc-reply.js +18 -34
  26. package/dist/lib/doc-reply.js.map +1 -1
  27. package/dist/lib/harness.d.ts +38 -0
  28. package/dist/lib/harness.js +139 -0
  29. package/dist/lib/harness.js.map +1 -0
  30. package/dist/lib/llm.d.ts +30 -2
  31. package/dist/lib/llm.js +284 -30
  32. package/dist/lib/llm.js.map +1 -1
  33. package/dist/lib/lock.d.ts +1 -1
  34. package/dist/lib/models.d.ts +11 -0
  35. package/dist/lib/models.js +30 -1
  36. package/dist/lib/models.js.map +1 -1
  37. package/dist/lib/orchestrator/client.d.ts +27 -0
  38. package/dist/lib/orchestrator/client.js +135 -0
  39. package/dist/lib/orchestrator/client.js.map +1 -0
  40. package/dist/lib/orchestrator/index.d.ts +5 -0
  41. package/dist/lib/orchestrator/index.js +17 -0
  42. package/dist/lib/orchestrator/index.js.map +1 -0
  43. package/dist/lib/orchestrator/runner.d.ts +18 -0
  44. package/dist/lib/orchestrator/runner.js +177 -0
  45. package/dist/lib/orchestrator/runner.js.map +1 -0
  46. package/dist/lib/orchestrator/worktree.d.ts +43 -0
  47. package/dist/lib/orchestrator/worktree.js +243 -0
  48. package/dist/lib/orchestrator/worktree.js.map +1 -0
  49. package/dist/lib/session-stream.d.ts +1 -1
  50. package/dist/lib/skills.d.ts +21 -0
  51. package/dist/lib/skills.js +80 -0
  52. package/dist/lib/skills.js.map +1 -0
  53. package/dist/lib/usage.d.ts +52 -0
  54. package/dist/lib/usage.js +69 -0
  55. package/dist/lib/usage.js.map +1 -0
  56. package/package.json +3 -1
  57. package/skills/teamshare-auth-rbac/SKILL.md +79 -0
  58. package/skills/teamshare-cdd/SKILL.md +145 -0
  59. package/skills/teamshare-modularity/SKILL.md +92 -0
  60. package/tools/usage_report.py +223 -0
package/dist/cli/index.js CHANGED
@@ -27,45 +27,128 @@ const chat_reply_1 = require("../lib/chat-reply");
27
27
  const doc_reply_1 = require("../lib/doc-reply");
28
28
  const config_1 = require("../lib/config");
29
29
  const lock_1 = require("../lib/lock");
30
+ const usage_1 = require("../lib/usage");
30
31
  const session_stream_1 = require("../lib/session-stream");
31
32
  const models_1 = require("../lib/models");
33
+ const harness_1 = require("../lib/harness");
34
+ const skills_1 = require("../lib/skills");
32
35
  const VERSION = '0.1.0';
33
36
  const WS_DEFAULT = 'ws://localhost:4000';
34
- /** Phase D settings resolution: CLI flag > agent settings (passed as flags) > env > default. */
35
- function modelOf(flags) {
36
- return flags.get('model') ?? process.env.LLM_MODEL ?? llm_1.DEFAULT_LLM_MODEL;
37
+ /** Build mode (IMP-700): the project chat channel for agent questions. */
38
+ const AGENT_QUESTIONS_CHANNEL = 'agent-questions';
39
+ /** Per-project build run state (skipped tasks awaiting answers) lives here. */
40
+ const BUILD_STATE_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), '.teamshare', 'builds');
41
+ /** Stop file: the daemon writes it on build.stop; the loop honors + deletes it. */
42
+ const STOP_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), '.teamshare', 'stops');
43
+ /** Best-effort fetch of the agent's stored settings - never fatal. */
44
+ async function fetchAgentSettings(api, agentId, log) {
45
+ try {
46
+ const s = await api.getAgentSettings(agentId);
47
+ log(`[settings] agent stored: model=${s.model ?? 'none'} temperature=${s.temperature ?? 'default'} maxTokens=${s.maxTokens ?? 'default'} harness=${s.harness ?? 'default'} prompt=${s.systemPrompt ? 'set' : 'none'} providerKey=${s.providerKey ? 'set' : 'none'}`);
48
+ return {
49
+ model: s.model ?? undefined,
50
+ temperature: s.temperature ?? undefined,
51
+ maxTokens: s.maxTokens ?? undefined,
52
+ systemPrompt: s.systemPrompt,
53
+ userId: s.userId ?? undefined,
54
+ harness: s.harness,
55
+ providerKey: s.providerKey,
56
+ };
57
+ }
58
+ catch (err) {
59
+ log(`[settings] could not fetch agent settings (${(0, config_1.msg)(err)}) - using flags/env`, 'stderr');
60
+ return {};
61
+ }
62
+ }
63
+ /** Session driver resolution: flag > agent setting > local config > auto. */
64
+ function harnessOf(flags, agent) {
65
+ return (flags.get('harness') ??
66
+ agent?.harness ??
67
+ (0, config_1.loadConfig)().harness ??
68
+ 'auto');
69
+ }
70
+ /**
71
+ * LLM provider key for self-loop / chat / doc sessions:
72
+ * server BYOK (settings.providerKey) > local config chatKey >
73
+ * CHAT_LLM_API_KEY > LLM_API_KEY env. Never logged.
74
+ */
75
+ function sessionLlmApiKey(agent) {
76
+ return agent?.providerKey ?? (0, config_1.chatKey)() ?? '';
77
+ }
78
+ /** Phase D settings resolution: CLI flag > agent settings > env > default. */
79
+ function modelOf(flags, agent) {
80
+ return (flags.get('model') ??
81
+ agent?.model ??
82
+ process.env.LLM_MODEL ??
83
+ llm_1.DEFAULT_LLM_MODEL);
37
84
  }
38
85
  /** Model spec -> { baseUrl, model } via the catalog (env LLM_BASE_URL wins). */
39
- function llmEndpoint(flags) {
40
- const spec = modelOf(flags);
86
+ function llmEndpoint(flags, agent) {
87
+ const spec = modelOf(flags, agent);
41
88
  const resolved = (0, models_1.resolveModel)(spec, process.env.LLM_BASE_URL);
42
- if (!resolved.catalogued) {
89
+ if (resolved.harnessOnly) {
90
+ console.log(`[model] "${spec}" is a harness-native model - must use opencode/claude harness, not --self`);
91
+ }
92
+ else if (!resolved.catalogued) {
43
93
  console.log(`[model] "${spec}" is not a catalogued provider:model spec - using LLM_BASE_URL (${resolved.baseUrl})`);
44
94
  }
45
- return { baseUrl: resolved.baseUrl, model: resolved.model };
95
+ return { baseUrl: resolved.baseUrl, model: resolved.model, harnessOnly: resolved.harnessOnly };
96
+ }
97
+ /**
98
+ * Applies the LLM provider key for harness runs: server BYOK
99
+ * (settings.providerKey) > per-agent key > global key (both from
100
+ * ~/.teamshare/config.json). Set as OPENCODE_API_KEY so the harness child
101
+ * inherits it - agent usage never bills the user's default opencode
102
+ * credentials. An env var already set always wins. The KEY VALUE IS NEVER
103
+ * LOGGED - only which source was used.
104
+ */
105
+ function applyOpenCodeKey(agentId, log, settings) {
106
+ if (process.env.OPENCODE_API_KEY) {
107
+ log('[key] OPENCODE_API_KEY env already set - leaving it untouched');
108
+ return;
109
+ }
110
+ const key = settings?.providerKey ?? (0, config_1.agentOpenCodeKey)(agentId);
111
+ if (!key) {
112
+ log('[key] no LLM provider key configured - opencode falls back to auth.json');
113
+ return;
114
+ }
115
+ process.env.OPENCODE_API_KEY = key;
116
+ log(settings?.providerKey
117
+ ? '[key] server BYOK provider key applied (isolated from your default credentials)'
118
+ : '[key] local agent LLM provider key applied (isolated from your default credentials)');
46
119
  }
47
- function temperatureOf(flags) {
48
- const raw = flags.get('temperature') ?? process.env.LLM_TEMPERATURE;
120
+ /** Harness kill-switch: max minutes an opencode/claude child may run (env TEAMSHARE_SESSION_TIMEOUT_MIN, default 60). */
121
+ function sessionTimeoutMin(flags) {
122
+ const raw = flags.get('session-timeout') ?? process.env.TEAMSHARE_SESSION_TIMEOUT_MIN;
123
+ const n = Number(raw);
124
+ return Number.isFinite(n) && n > 0 ? n : 60;
125
+ }
126
+ function temperatureOf(flags, agent) {
127
+ const raw = flags.get('temperature') ??
128
+ (agent?.temperature !== undefined ? String(agent.temperature) : undefined) ??
129
+ process.env.LLM_TEMPERATURE;
49
130
  if (raw === undefined)
50
131
  return undefined;
51
132
  const n = Number(raw);
52
133
  return Number.isFinite(n) ? Math.min(2, Math.max(0, n)) : undefined;
53
134
  }
54
- function maxTokensOf(flags) {
55
- const raw = flags.get('max-tokens') ?? process.env.LLM_MAX_TOKENS;
135
+ function maxTokensOf(flags, agent) {
136
+ const raw = flags.get('max-tokens') ??
137
+ (agent?.maxTokens !== undefined ? String(agent.maxTokens) : undefined) ??
138
+ process.env.LLM_MAX_TOKENS;
56
139
  if (raw === undefined)
57
140
  return undefined;
58
141
  const n = Number(raw);
59
142
  return Number.isInteger(n) && n > 0 ? n : undefined;
60
143
  }
61
- function systemPromptOf(flags) {
144
+ function systemPromptOf(flags, agent) {
62
145
  const inline = flags.get('system-prompt');
63
146
  if (inline !== undefined)
64
147
  return inline;
65
148
  const file = flags.get('system-prompt-file');
66
149
  if (file && (0, node_fs_1.existsSync)(file))
67
150
  return (0, node_fs_1.readFileSync)(file, 'utf8');
68
- return null;
151
+ return agent?.systemPrompt ?? null;
69
152
  }
70
153
  /** Console + live-stream logger for a session. */
71
154
  function makeLog(stream) {
@@ -82,6 +165,15 @@ function usage() {
82
165
  ' teamshare-agent connect --agent <agentId> --key <ts_...>',
83
166
  ' Test the connection and save the agent key in ~/.teamshare/config.json.',
84
167
  '',
168
+ ' teamshare-agent link --project <projectId> --path <folder> [--guard true|false]',
169
+ ' PROJECT GUARD: bind a TeamShare project to a local folder. The agent',
170
+ ' refuses to work a project until it is linked, and every session then',
171
+ ' runs inside that folder (read-only warning for OneDrive/network/',
172
+ ' outside-profile paths - those are blocked).',
173
+ ' teamshare-agent link --list',
174
+ ' teamshare-agent unlink --project <projectId>',
175
+ ' Remove the link - sessions for that project are blocked again.',
176
+ '',
85
177
  ' teamshare-agent run --task <taskId> --agent <agentId> --key <ts_...> [options]',
86
178
  ' Fetch the task brief and work it with a harness or headless LLM.',
87
179
  ' Without --task, picks up the agent\'s next assigned task from its inbox',
@@ -90,28 +182,71 @@ function usage() {
90
182
  ' teamshare-agent continue --task <taskId> --agent <agentId> --key <ts_...> [--self]',
91
183
  ' Resume the previous --self session for a task (Phase E continuation).',
92
184
  '',
93
- ' teamshare-agent chat --project <projectId> --agent <agentId> --key <ts_...> [--mention <token>]',
94
- ' Reply to every project-chat message that @mentions the agent (one reply',
95
- ' per message). Spawned by the daemon on chat.mention wakes.',
185
+ ' teamshare-agent chat --project <projectId> --agent <agentId> --key <ts_...> [--mention <token>] [--target <messageId>]',
186
+ ' Reply to ONE project-chat message that @mentions the agent (one mention',
187
+ ' → one reply → sleep). --target pins the reply to that specific message;',
188
+ ' without it the latest mention is used. Spawned by the daemon on chat.mention wakes.',
96
189
  '',
97
190
  ' teamshare-agent doc --document <documentId> --agent <agentId> --key <ts_...> [--mention <token>]',
98
191
  ' Document assistant (Phase F): read the document (Word/PPT/Excel/PDF)',
99
192
  ' and reply to every comment that @mentions the agent in its thread.',
100
193
  ' Spawned by the daemon on document.mention wakes.',
101
194
  '',
195
+ ' teamshare-agent build --project <projectId> --agent <agentId> --key <ts_...> [options]',
196
+ ' BUILD MODE: work the project\'s ready-for-dev queue in strict order',
197
+ ' (sortOrder asc, createdAt asc), one task at a time, fully completing',
198
+ ' each. A failed task is skipped: a [question] comment + #agent-questions',
199
+ ' message ask the team what to do, the build continues, and the task is',
200
+ ' resumed automatically once a human answers. Pass --once to stop after',
201
+ ' the first task; --dry-run prints the queue without working anything.',
202
+ ' teamshare-agent build --project <id> --agent <id> --key <ts_...> --draft "<goal>" [--go]',
203
+ ' Draft the build queue for a goal: the agent plans 3-8 concrete tasks,',
204
+ ' creates them ready-for-dev in order and assigns them to itself. --go',
205
+ ' executes the queue immediately afterwards; without it just the plan.',
206
+ '',
207
+ ' teamshare-agent usage [--day YYYY-MM-DD] [--agent <agentId>]',
208
+ ' Agent spend report - read-only daily cost/token view of the local',
209
+ ' opencode db (~/.local/share/opencode/opencode.db). Offline; needs',
210
+ ' neither the backend nor the daemon. Attribution: "TeamShare "',
211
+ ' titles = agent sessions, providerID opencode = your own windows.',
212
+ '',
213
+ ' teamshare-agent skills install|list|uninstall',
214
+ ' Install the TeamShare agent skills (teamshare-cdd, teamshare-modularity,',
215
+ ' teamshare-auth-rbac) into the opencode skills directory (~/.agents/skills',
216
+ ' by default; TEAMSHARE_SKILLS_DIR overrides). Agents working TeamShare tasks',
217
+ ' are expected to load them and follow their rules.',
218
+ '',
219
+ ' teamshare-agent key set --key <providerApiKey> [--agent <agentId>]',
220
+ ' teamshare-agent key show | clear [--agent <agentId>]',
221
+ ' LLM provider key for agent harness runs (e.g. an opencode-go API key).',
222
+ ' Without --agent it is the global default for every agent; with --agent',
223
+ ' it overrides that agent only. Agents run with their OWN key so usage',
224
+ ' never bills your default opencode credentials. Keys are never printed.',
225
+ '',
102
226
  'OPTIONS',
103
227
  ' --harness auto|opencode|claude|none session driver (default: config or auto)',
104
228
  ' --self headless LLM loop (needs LLM_API_KEY env)',
105
229
  ' --no-report skip the closing summary comment',
106
230
  ' --continue resume the previous --self session for this task (Phase E)',
107
231
  ' --session <id> live-console session id (default: auto)',
232
+ ' --cwd <folder> work inside this folder (else the linked',
233
+ ' project folder; hard-blocked if neither)',
108
234
  ' --model <id> model override (Phase D; env LLM_MODEL)',
109
235
  ' --temperature <0-2> sampling temperature (Phase D; env LLM_TEMPERATURE)',
110
236
  ' --max-tokens <n> token budget (Phase D; env LLM_MAX_TOKENS)',
111
237
  ' --system-prompt <text> system prompt override (Phase D)',
112
238
  ' --system-prompt-file <path> system prompt from a file (Phase D)',
113
239
  ' --ask-timeout <sec> ask_human wait limit (Phase B; env TEAMSHARE_ASK_TIMEOUT)',
240
+ ' --session-timeout <min> harness kill-switch cap (default 60; env TEAMSHARE_SESSION_TIMEOUT_MIN)',
241
+ ' --once build: stop after the first task',
242
+ ' --dry-run build: print the queue, work nothing',
243
+ ' --draft <goal> build: plan the queue for a goal (creates tasks)',
244
+ ' --go build: execute the queue after --draft',
245
+ ' --on-fail ask|stop build: ask a question (resume later) or stop the run on failure (default ask)',
246
+ ' --blocked-wait <min> build: how long to wait for answers before exiting (default 30)',
114
247
  ' --api-url <url> TeamShare API base (default: ' + api_1.DEFAULT_API_URL + ')',
248
+ ' --day <YYYY-MM-DD> usage report day (default: today)',
249
+ ' --agent <agentId> usage report filter (title contains "agent <id>")',
115
250
  ' -h, --help this help',
116
251
  '',
117
252
  'ENV',
@@ -166,8 +301,34 @@ function need(flags, name) {
166
301
  }
167
302
  return value;
168
303
  }
169
- function harnessOf(flags) {
170
- return (flags.get('harness') ?? (0, config_1.loadConfig)().harness ?? 'auto');
304
+ /**
305
+ * Project guard (IMP-6xx): resolve the working folder for a session. An
306
+ * explicit `--cwd` wins; otherwise the project's linked folder from the
307
+ * local config is used. Returns null (blocked) when no folder is available
308
+ * - the caller must refuse the session.
309
+ */
310
+ function resolveWorkingDir(flags, projectId, log) {
311
+ const explicit = flags.get('cwd');
312
+ if (explicit) {
313
+ const abs = (0, node_path_1.resolve)(explicit);
314
+ if (!(0, node_fs_1.existsSync)(abs) || !(0, node_fs_1.statSync)(abs).isDirectory()) {
315
+ log(`[guard] --cwd is not an existing folder: ${abs}`, 'stderr');
316
+ return null;
317
+ }
318
+ log(`[guard] working folder: ${abs}`);
319
+ return abs;
320
+ }
321
+ const cwd = projectId ? (0, config_1.projectCwd)(projectId) : null;
322
+ if (!cwd) {
323
+ log(projectId
324
+ ? `[guard] BLOCKED - project ${projectId} has no linked folder.\n` +
325
+ ` Link one first:\n ${(0, config_1.linkHint)(projectId)}`
326
+ : `[guard] BLOCKED - could not determine the project; no folder to work in.\n` +
327
+ ` Link one first:\n teamshare-agent link --project <projectId> --path C:\\path\\to\\folder`, 'stderr');
328
+ return null;
329
+ }
330
+ log(`[guard] working folder: ${cwd}`);
331
+ return cwd;
171
332
  }
172
333
  async function cmdConnect(flags) {
173
334
  const agentId = need(flags, 'agent');
@@ -176,15 +337,112 @@ async function cmdConnect(flags) {
176
337
  await api.heartbeat(agentId, 'online');
177
338
  (0, config_1.upsertAgent)(agentId, apiKey);
178
339
  console.log(`Connected: agent ${agentId} (key saved to ~/.teamshare/config.json)`);
340
+ // Wire TeamShare MCP into opencode so the agent has tools in sessions.
341
+ const wired = (0, config_1.wireOpencodeMcp)(apiKey);
342
+ console.log(wired
343
+ ? '[mcp] wired TeamShare MCP into opencode.jsonc — agent now has 21 tools (create_task, list_tasks, search, etc.)'
344
+ : '[mcp] TeamShare MCP already wired in opencode.jsonc — skipping');
179
345
  console.log('Ready: teamshare-agent run --task <taskId> --agent ' + agentId + '');
180
346
  }
347
+ /** `link` - project guard: bind a TeamShare project to a local folder. */
348
+ async function cmdLink(flags) {
349
+ if (flags.get('list') === 'true') {
350
+ const entries = Object.entries((0, config_1.loadConfig)().projects);
351
+ if (entries.length === 0) {
352
+ console.log('No projects linked yet - run: teamshare-agent link --project <id> --path <folder>');
353
+ return;
354
+ }
355
+ for (const [projectId, link] of entries) {
356
+ console.log(`${projectId} -> ${link.path}${link.guard === false ? ' (guard off)' : ''}`);
357
+ }
358
+ return;
359
+ }
360
+ const projectId = need(flags, 'project');
361
+ const path = need(flags, 'path');
362
+ const result = (0, config_1.linkProject)(projectId, path, flags.get('guard') !== 'false');
363
+ if (!result.ok) {
364
+ console.error(`Could not link project ${projectId}:`);
365
+ console.error(' ' + result.reason);
366
+ process.exitCode = 1;
367
+ return;
368
+ }
369
+ console.log(`Linked project ${projectId} -> ${result.canonical}`);
370
+ console.log('The agent now works inside this folder for that project (guard on).');
371
+ }
372
+ /** `unlink` - remove a project link. */
373
+ async function cmdUnlink(flags) {
374
+ const projectId = need(flags, 'project');
375
+ const config = (0, config_1.unlinkProject)(projectId);
376
+ const gone = !config.projects[projectId];
377
+ if (gone)
378
+ console.log(`Unlinked project ${projectId} - its sessions are now blocked by the guard.`);
379
+ else {
380
+ console.error(`Project ${projectId} was not linked.`);
381
+ process.exitCode = 1;
382
+ }
383
+ }
384
+ /** `usage` - read-only daily spend report from the local opencode db. */
385
+ async function cmdUsage(flags) {
386
+ try {
387
+ const report = (0, usage_1.runUsageReport)({
388
+ day: flags.get('day') ?? undefined,
389
+ agent: flags.get('agent') ?? undefined,
390
+ });
391
+ console.log(`Agent spend report - ${report.day} (local time)`);
392
+ console.log(`Source: ${report.dbPath} (read-only)`);
393
+ console.log(`Total: $${report.totalCost.toFixed(4)} across ${report.sessionCount} sessions`);
394
+ console.log(` your own windows : $${report.userCost.toFixed(4)}`);
395
+ console.log(` agent sessions : $${report.agentCost.toFixed(4)} (of which TeamShare $${report.teamshareCost.toFixed(4)})`);
396
+ const t = report.tokens;
397
+ console.log(`Tokens: ${t.input.toLocaleString()} in / ${t.output.toLocaleString()} out / ` +
398
+ `${t.reasoning.toLocaleString()} reasoning / ${t.cacheRead.toLocaleString()} cache-read`);
399
+ if (report.sessions.length === 0) {
400
+ console.log('No cost-bearing sessions for this day.');
401
+ return;
402
+ }
403
+ console.log('');
404
+ console.log('time dur cost src model title');
405
+ console.log('-'.repeat(120));
406
+ for (const s of report.sessions) {
407
+ const src = s.attribution === 'user' ? 'user' : s.teamshare ? 'teamshare' : 'agent';
408
+ const start = new Date(s.timeCreated).toLocaleTimeString([], {
409
+ hour: '2-digit',
410
+ minute: '2-digit',
411
+ });
412
+ const end = new Date(s.timeUpdated).toLocaleTimeString([], {
413
+ hour: '2-digit',
414
+ minute: '2-digit',
415
+ });
416
+ const model = `${s.providerID}/${s.model}`.slice(0, 34);
417
+ console.log(`${start}-${end} ${String(s.durationMin.toFixed(1)).padStart(5)}m ` +
418
+ `${s.cost.toFixed(4).padStart(7)} ${src.padEnd(9)} ${model.padEnd(34)} ${s.title.slice(0, 60)}`);
419
+ }
420
+ console.log('');
421
+ console.log(`Takeaway: agents cost $${report.agentCost.toFixed(4)} vs your own windows ` +
422
+ `$${report.userCost.toFixed(4)} on ${report.day}.`);
423
+ }
424
+ catch (err) {
425
+ console.error(`[usage] ${(0, config_1.msg)(err)}`);
426
+ process.exitCode = 1;
427
+ }
428
+ }
181
429
  async function cmdRun(flags) {
182
430
  const agentId = need(flags, 'agent');
183
431
  const apiKey = need(flags, 'key');
184
- const selfMode = flags.get('self') === 'true';
432
+ let selfMode = flags.get('self') === 'true';
185
433
  const noReport = flags.get('no-report') === 'true';
186
- const harness = harnessOf(flags);
187
434
  const api = new api_1.TeamshareApi(process.env.TEAMSHARE_API_URL, apiKey);
435
+ // Phase D: apply the agent's stored model/harness/providerKey/etc.
436
+ // (flag > agent setting > env > default) for direct CLI sessions.
437
+ const settings = await fetchAgentSettings(api, agentId, makeLog(null));
438
+ const harness = harnessOf(flags, settings);
439
+ // If model is harness-only (opencode-go), force harness mode instead of --self
440
+ const modelSpec = modelOf(flags);
441
+ const modelResolved = (0, models_1.resolveModel)(modelSpec, process.env.LLM_BASE_URL);
442
+ if (modelResolved.harnessOnly && selfMode) {
443
+ console.log(`[model] "${modelSpec}" is harness-native - switching from --self to harness mode`);
444
+ selfMode = false;
445
+ }
188
446
  // One session per agent at a time - wait for any in-flight session to finish.
189
447
  const lock = await (0, lock_1.acquireLock)(agentId, 'task', {
190
448
  taskId: flags.get('task') ?? undefined,
@@ -197,15 +455,24 @@ async function cmdRun(flags) {
197
455
  }
198
456
  let exitCode = 0;
199
457
  let heartbeatTimer;
458
+ let claimedTaskId;
200
459
  const sessionId = flags.get('session') ?? (0, session_stream_1.newSessionId)();
201
460
  const stream = new session_stream_1.SessionStream({ sessionId, agentId, mode: 'run' });
202
- stream.connect((process.env.TEAMSHARE_WS_URL ?? WS_DEFAULT).replace(/\/$/, '') + '/agents', apiKey);
203
461
  const log = makeLog(stream);
462
+ applyOpenCodeKey(agentId, log, settings);
204
463
  try {
205
464
  // Explicit --task wins; otherwise pick up the agent's next assigned task
206
465
  // from its inbox (urgent > high > medium > low, then oldest; anything
207
466
  // in_review/resolved/closed is skipped).
208
467
  let taskId = flags.get('task');
468
+ // Defense: a bare `--task` (no value) parses as boolean "true" via
469
+ // parseArgs - never treat a non-UUID as a task id. Fall back to picking
470
+ // the agent's next assigned task instead of failing every UUID check.
471
+ if (taskId &&
472
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(taskId)) {
473
+ log(`[pickup] --task ignored (not a UUID: "${taskId}") - picking the next assigned task`, 'stderr');
474
+ taskId = undefined;
475
+ }
209
476
  if (!taskId) {
210
477
  const picked = await pickNextTask(api, agentId, log);
211
478
  if (!picked) {
@@ -214,9 +481,51 @@ async function cmdRun(flags) {
214
481
  }
215
482
  taskId = picked;
216
483
  }
217
- const task = await api.getTask(taskId).catch(() => null);
218
- stream.meta.projectId = task?.projectId ?? undefined;
219
- log(`[teamshare-agent] task ${taskId} as agent ${agentId} (${selfMode ? 'headless' : harness})`);
484
+ // PROJECT GUARD (IMP-6xx): resolve the working folder for this task
485
+ // before any work - unlinked projects are hard-blocked (the agent only
486
+ // ever runs inside a folder you linked).
487
+ const task = await api.getTask(taskId).catch((err) => {
488
+ log(`[guard] FAILED to fetch task ${taskId}: ${err.message ?? err}`, 'stderr');
489
+ return null;
490
+ });
491
+ if (!task) {
492
+ log(`[guard] Cannot access task ${taskId} - stopping. Verify project membership.`, 'stderr');
493
+ exitCode = 1;
494
+ return;
495
+ }
496
+ stream.meta.projectId = task.projectId ?? undefined;
497
+ stream.meta.taskId = taskId;
498
+ // Connect the session stream now that the meta is known, so session:start
499
+ // carries taskId/projectId (earlier lines replay from the buffer). Late
500
+ // connect also means blocked/early-exit sessions still stream their exit.
501
+ stream.connect((process.env.TEAMSHARE_WS_URL ?? WS_DEFAULT).replace(/\/$/, '') + '/agents', apiKey);
502
+ const cwd = resolveWorkingDir(flags, task?.projectId ?? null, log);
503
+ if (!cwd) {
504
+ exitCode = 1;
505
+ return;
506
+ }
507
+ // Phase D settings were fetched before the lock; the run continues below.
508
+ // IMP-560: claim the task so no other agent works it concurrently (the
509
+ // server-side lease renews on every mutation and auto-releases here on
510
+ // session end). Hard refusals: claimed by someone else, locked to its
511
+ // agent assignee, or blocked by the project/task agent-work policy.
512
+ try {
513
+ const claim = await api.claimTask(taskId);
514
+ claimedTaskId = taskId;
515
+ log(`[claim] task ${taskId} claimed - lease until ${claim.claimExpiresAt}`);
516
+ }
517
+ catch (err) {
518
+ const e = err;
519
+ if (e.code === 'TASK_CLAIMED_BY' ||
520
+ e.code === 'AGENT_TASK_LOCKED' ||
521
+ e.code === 'AGENT_UNASSIGNED_BLOCKED') {
522
+ log(`[claim] cannot work task ${taskId}: ${e.message ?? e.code}`, 'stderr');
523
+ exitCode = 1;
524
+ return;
525
+ }
526
+ log(`[claim] warning: could not claim task ${taskId}: ${e.message ?? e.code}`, 'stderr');
527
+ }
528
+ log(`[teamshare-agent] task ${taskId} as agent ${agentId} (${selfMode ? 'headless' : harness}) in ${cwd}`);
220
529
  await (0, brief_1.reportStatus)(api, agentId, 'working', taskId, sessionId);
221
530
  // Keep lastSeenAt fresh during long sessions so the backend liveness
222
531
  // sweep never mistakes a working agent for a dead one.
@@ -233,9 +542,10 @@ async function cmdRun(flags) {
233
542
  log('[brief] --- end preview ---');
234
543
  let summary = '';
235
544
  if (selfMode) {
236
- const llmApiKey = process.env.LLM_API_KEY;
545
+ // BYOK: server-stored provider key > local config > env.
546
+ const llmApiKey = sessionLlmApiKey(settings);
237
547
  if (!llmApiKey) {
238
- log('--self requires LLM_API_KEY env (and optionally LLM_BASE_URL / LLM_MODEL)', 'stderr');
548
+ log('--self requires a provider API key (set it on the agent in TeamShare, or LLM_API_KEY env)', 'stderr');
239
549
  exitCode = 1;
240
550
  return;
241
551
  }
@@ -243,34 +553,44 @@ async function cmdRun(flags) {
243
553
  if (flags.get('ask-timeout')) {
244
554
  process.env.TEAMSHARE_ASK_TIMEOUT = flags.get('ask-timeout') ?? '';
245
555
  }
246
- const endpoint = llmEndpoint(flags);
556
+ const endpoint = llmEndpoint(flags, settings);
247
557
  log(`[model] ${endpoint.model} @ ${endpoint.baseUrl}`);
558
+ const task = await api.getTask(taskId).catch(() => null);
248
559
  const result = await (0, llm_1.runLlmSession)(api, taskId, brief, {
249
560
  baseUrl: endpoint.baseUrl,
250
561
  apiKey: llmApiKey,
251
562
  model: endpoint.model,
252
563
  agentName: agentId,
253
- systemPrompt: systemPromptOf(flags),
254
- temperature: temperatureOf(flags),
255
- maxTokens: maxTokensOf(flags),
564
+ systemPrompt: systemPromptOf(flags, settings),
565
+ temperature: temperatureOf(flags, settings),
566
+ maxTokens: maxTokensOf(flags, settings),
256
567
  continue: flags.get('continue') === 'true',
257
- });
568
+ agentUserId: settings.userId ?? undefined,
569
+ }, task?.projectId);
258
570
  summary =
259
571
  result.summary ||
260
572
  `Agent run finished after ${result.iterations} iterations (${result.tokensUsed} tokens).`;
261
573
  log(`[session] done: ${result.finishedBy} | ${result.iterations} iterations | ${result.tokensUsed} tokens`);
262
574
  }
263
575
  else {
264
- const systemPrompt = systemPromptOf(flags);
576
+ const systemPrompt = systemPromptOf(flags, settings);
265
577
  const prompt = `Work on the TeamShare task described in ${briefPath} using the teamshare MCP tools. ` +
266
578
  `Break the task down first: create 3-8 subtasks with create_subtask, then check each off ` +
267
579
  `(update_subtask with done: true) as you complete it. ` +
580
+ (settings.userId
581
+ ? `Your TeamShare user id is ${settings.userId} - when a task or the brief asks you to assign a task to yourself, pass assigneeId "me" (resolves to your user id).\n`
582
+ : '') +
268
583
  (systemPrompt ? `Follow these instructions: ${systemPrompt}\n\n` : '') +
269
584
  `Keep the human informed with progress comments on the task. When done, post a detailed ` +
270
585
  `summary comment (mention the subtask progress) and move the task to in_review (or resolved if you are confident).`;
271
586
  exitCode = await spawnHarness(harness, prompt, log, {
272
- model: flags.get('model') ?? undefined,
273
- temperature: temperatureOf(flags),
587
+ model: modelOf(flags, settings),
588
+ temperature: temperatureOf(flags, settings),
589
+ cwd,
590
+ timeoutMin: sessionTimeoutMin(flags),
591
+ title: `TeamShare task ${taskId} (agent ${agentId})`,
592
+ agentKey: api.apiKey,
593
+ apiBaseUrl: api.baseUrl,
274
594
  });
275
595
  summary = `Harness (${harness}) finished the session - see the task comments.`;
276
596
  }
@@ -287,6 +607,10 @@ async function cmdRun(flags) {
287
607
  finally {
288
608
  if (heartbeatTimer)
289
609
  clearInterval(heartbeatTimer);
610
+ if (claimedTaskId) {
611
+ await api.releaseTask(claimedTaskId).catch(() => undefined);
612
+ log(`[claim] task ${claimedTaskId} released`);
613
+ }
290
614
  (0, lock_1.releaseLock)(agentId);
291
615
  await stream.end(exitCode);
292
616
  }
@@ -300,13 +624,21 @@ async function cmdDoc(flags) {
300
624
  const apiKey = need(flags, 'key');
301
625
  const mention = flags.get('mention') ?? '';
302
626
  const selfMode = flags.get('self') === 'true';
303
- const harness = harnessOf(flags);
304
627
  let exitCode = 0;
305
628
  const sessionId = flags.get('session') ?? (0, session_stream_1.newSessionId)();
306
629
  const stream = new session_stream_1.SessionStream({ sessionId, agentId, mode: 'chat' });
307
630
  stream.connect((process.env.TEAMSHARE_WS_URL ?? WS_DEFAULT).replace(/\/$/, '') + '/agents', apiKey);
308
631
  const log = makeLog(stream);
309
632
  const api = new api_1.TeamshareApi(process.env.TEAMSHARE_API_URL, apiKey);
633
+ // PROJECT GUARD (IMP-6xx): hard-block document sessions for documents
634
+ // whose project has no linked local folder.
635
+ const doc = await api.getDocument(documentId).catch(() => null);
636
+ const cwd = resolveWorkingDir(flags, doc?.projectId ?? null, log);
637
+ if (!cwd)
638
+ return;
639
+ const settings = await fetchAgentSettings(api, agentId, log);
640
+ const harness = harnessOf(flags, settings);
641
+ applyOpenCodeKey(agentId, log, settings);
310
642
  const lock = await (0, lock_1.acquireLock)(agentId, 'doc', { documentId }, {
311
643
  onWaiting: (busy) => console.log(`[lock] agent ${agentId} is busy (pid ${busy.pid}, since ${busy.startedAt}, ${busy.kind}) - waiting for it to finish...`),
312
644
  });
@@ -316,7 +648,7 @@ async function cmdDoc(flags) {
316
648
  }
317
649
  try {
318
650
  await (0, brief_1.reportStatus)(api, agentId, 'working', undefined, sessionId);
319
- log(`[teamshare-agent] document assistant (document ${documentId}${mention ? `, mention @${mention}` : 'all mentions'})`);
651
+ log(`[teamshare-agent] document assistant (document ${documentId}${mention ? `, mention @${mention}` : 'all mentions'}) in ${cwd}`);
320
652
  const heartbeatTimer = setInterval(() => {
321
653
  void (0, brief_1.reportStatus)(api, agentId, 'working', undefined, sessionId);
322
654
  }, 60_000);
@@ -325,12 +657,13 @@ async function cmdDoc(flags) {
325
657
  harness,
326
658
  selfMode,
327
659
  echo: process.env.TS_CHAT_ECHO === '1',
660
+ api,
328
661
  llm: {
329
- baseUrl: llmEndpoint(flags).baseUrl,
330
- apiKey: process.env.LLM_API_KEY ?? '',
331
- model: llmEndpoint(flags).model,
332
- temperature: temperatureOf(flags),
333
- maxTokens: maxTokensOf(flags),
662
+ baseUrl: llmEndpoint(flags, settings).baseUrl,
663
+ apiKey: sessionLlmApiKey(settings),
664
+ model: llmEndpoint(flags, settings).model,
665
+ temperature: temperatureOf(flags, settings),
666
+ maxTokens: maxTokensOf(flags, settings),
334
667
  },
335
668
  });
336
669
  log(`[done] replied to ${result.replied} mention${result.replied === 1 ? '' : 's'}` +
@@ -350,20 +683,28 @@ async function cmdDoc(flags) {
350
683
  await stream.end(exitCode);
351
684
  }
352
685
  }
353
- /** Chat reply loop: reply to every message mentioning the agent. */
686
+ /** Chat reply loop: reply to a single mentioning message (the triggering one). */
354
687
  async function cmdChat(flags) {
355
688
  const projectId = need(flags, 'project');
356
689
  const agentId = need(flags, 'agent');
357
690
  const apiKey = need(flags, 'key');
358
691
  const mention = flags.get('mention') ?? '';
692
+ const targetId = flags.get('target');
359
693
  const selfMode = flags.get('self') === 'true';
360
- const harness = harnessOf(flags);
361
694
  let exitCode = 0;
362
695
  const sessionId = flags.get('session') ?? (0, session_stream_1.newSessionId)();
363
696
  const stream = new session_stream_1.SessionStream({ sessionId, agentId, projectId, mode: 'chat' });
364
697
  stream.connect((process.env.TEAMSHARE_WS_URL ?? WS_DEFAULT).replace(/\/$/, '') + '/agents', apiKey);
365
698
  const log = makeLog(stream);
366
699
  const api = new api_1.TeamshareApi(process.env.TEAMSHARE_API_URL, apiKey);
700
+ // PROJECT GUARD (IMP-6xx): hard-block chat sessions for projects without
701
+ // a linked local folder.
702
+ const cwd = resolveWorkingDir(flags, projectId, log);
703
+ if (!cwd)
704
+ return;
705
+ const settings = await fetchAgentSettings(api, agentId, log);
706
+ const harness = harnessOf(flags, settings);
707
+ applyOpenCodeKey(agentId, log, settings);
367
708
  const lock = await (0, lock_1.acquireLock)(agentId, 'chat', { projectId }, {
368
709
  onWaiting: (busy) => console.log(`[lock] agent ${agentId} is busy (pid ${busy.pid}, since ${busy.startedAt}, ${busy.kind}) - waiting for it to finish...`),
369
710
  });
@@ -373,22 +714,22 @@ async function cmdChat(flags) {
373
714
  }
374
715
  try {
375
716
  await (0, brief_1.reportStatus)(api, agentId, 'working', undefined, sessionId);
376
- log(`[teamshare-agent] chat reply loop (project ${projectId}${mention ? `, mention @${mention}` : 'all mentions'})`);
717
+ log(`[teamshare-agent] chat reply loop (project ${projectId}${mention ? `, mention @${mention}` : 'all mentions'}) in ${cwd}`);
377
718
  const heartbeatTimer = setInterval(() => {
378
719
  void (0, brief_1.reportStatus)(api, agentId, 'working', undefined, sessionId);
379
720
  }, 60_000);
380
721
  try {
381
- const endpoint = llmEndpoint(flags);
382
722
  const result = await (0, chat_reply_1.runChatReply)(api, agentId, projectId, mention, {
383
723
  harness,
384
724
  selfMode,
725
+ api,
385
726
  echo: process.env.TS_CHAT_ECHO === '1',
386
727
  llm: {
387
- baseUrl: endpoint.baseUrl,
388
- apiKey: process.env.LLM_API_KEY ?? '',
389
- model: endpoint.model,
390
- temperature: temperatureOf(flags),
391
- maxTokens: maxTokensOf(flags),
728
+ baseUrl: (llmEndpoint(flags, settings)).baseUrl,
729
+ apiKey: sessionLlmApiKey(settings),
730
+ model: (llmEndpoint(flags, settings)).model,
731
+ temperature: temperatureOf(flags, settings),
732
+ maxTokens: maxTokensOf(flags, settings),
392
733
  },
393
734
  // Phase C: stream generated replies into the channel as drafts.
394
735
  stream: {
@@ -396,7 +737,7 @@ async function cmdChat(flags) {
396
737
  apiKey,
397
738
  projectId,
398
739
  },
399
- });
740
+ }, targetId);
400
741
  log(`[done] replied to ${result.replied} mention${result.replied === 1 ? '' : 's'}` +
401
742
  (result.skipped ? ` (${result.skipped} skipped)` : ''));
402
743
  }
@@ -414,6 +755,510 @@ async function cmdChat(flags) {
414
755
  await stream.end(exitCode);
415
756
  }
416
757
  }
758
+ function buildStatePath(agentId, projectId) {
759
+ return (0, node_path_1.join)(BUILD_STATE_DIR, `${agentId}.${projectId}.json`);
760
+ }
761
+ function loadBuildState(agentId, projectId) {
762
+ try {
763
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(buildStatePath(agentId, projectId), 'utf8'));
764
+ return {
765
+ projectId,
766
+ skipped: Array.isArray(parsed.skipped) ? parsed.skipped : [],
767
+ done: Number.isFinite(parsed.done) ? parsed.done : 0,
768
+ updatedAt: parsed.updatedAt ?? new Date().toISOString(),
769
+ };
770
+ }
771
+ catch {
772
+ return { projectId, skipped: [], done: 0, updatedAt: new Date().toISOString() };
773
+ }
774
+ }
775
+ function saveBuildState(agentId, projectId, state) {
776
+ try {
777
+ (0, node_fs_1.mkdirSync)(BUILD_STATE_DIR, { recursive: true });
778
+ (0, node_fs_2.writeFileSync)(buildStatePath(agentId, projectId), JSON.stringify({ ...state, updatedAt: new Date().toISOString() }, null, 2));
779
+ }
780
+ catch {
781
+ /* best-effort - the in-memory state still drives this process */
782
+ }
783
+ }
784
+ function clearBuildState(agentId, projectId) {
785
+ try {
786
+ (0, node_fs_1.rmSync)(buildStatePath(agentId, projectId), { force: true });
787
+ }
788
+ catch {
789
+ /* best-effort */
790
+ }
791
+ }
792
+ /** Stop file: the daemon writes it on build.stop; the loop honors + deletes it. */
793
+ function stopFilePath(agentId) {
794
+ return (0, node_path_1.join)(STOP_DIR, `${agentId}.stop`);
795
+ }
796
+ function writeStopFile(agentId) {
797
+ try {
798
+ (0, node_fs_1.mkdirSync)(STOP_DIR, { recursive: true });
799
+ (0, node_fs_2.writeFileSync)(stopFilePath(agentId), new Date().toISOString(), 'utf8');
800
+ }
801
+ catch {
802
+ /* best-effort */
803
+ }
804
+ }
805
+ function hasStopFile(agentId) {
806
+ return (0, node_fs_1.existsSync)(stopFilePath(agentId));
807
+ }
808
+ function clearStopFile(agentId) {
809
+ try {
810
+ (0, node_fs_1.rmSync)(stopFilePath(agentId), { force: true });
811
+ }
812
+ catch {
813
+ /* best-effort */
814
+ }
815
+ }
816
+ /** The `build` command: guard + lock + run loop (or --draft / --dry-run). */
817
+ async function cmdBuild(flags) {
818
+ const agentId = need(flags, 'agent');
819
+ const apiKey = need(flags, 'key');
820
+ const projectId = need(flags, 'project');
821
+ const api = new api_1.TeamshareApi(process.env.TEAMSHARE_API_URL, apiKey);
822
+ const log = makeLog(null);
823
+ const settings = await fetchAgentSettings(api, agentId, log);
824
+ const harness = harnessOf(flags, settings);
825
+ let selfMode = flags.get('self') === 'true';
826
+ const modelSpec = modelOf(flags, settings);
827
+ const modelResolved = (0, models_1.resolveModel)(modelSpec, process.env.LLM_BASE_URL);
828
+ if (modelResolved.harnessOnly && selfMode) {
829
+ log(`[model] "${modelSpec}" is harness-native - switching from --self to harness mode`);
830
+ selfMode = false;
831
+ }
832
+ // --dry-run: print the queue, work nothing (no lock, no session).
833
+ if (flags.get('dry-run') === 'true') {
834
+ await cmdBuildDryRun(api, projectId, log);
835
+ return;
836
+ }
837
+ // --draft "<goal>": plan the queue first (creates ready-for-dev tasks).
838
+ const draftGoal = flags.get('draft');
839
+ if (draftGoal && draftGoal !== 'true') {
840
+ log(`[draft] planning the build queue for: ${draftGoal}`);
841
+ const planned = await draftBuildPlan({ api, agentId, projectId, settings, flags, log });
842
+ if (!planned) {
843
+ console.error('[draft] planning failed - see the log above');
844
+ process.exitCode = 1;
845
+ return;
846
+ }
847
+ if (flags.get('go') !== 'true') {
848
+ console.log('\nPlan created - the tasks are ready-for-dev and ordered.');
849
+ console.log('Run again with --go (or start the build from the app) to execute the queue.');
850
+ return;
851
+ }
852
+ }
853
+ // Project guard: resolve the working folder before any work.
854
+ const stream = new session_stream_1.SessionStream({ sessionId: flags.get('session') ?? (0, session_stream_1.newSessionId)(), agentId, mode: 'build', projectId });
855
+ const cwd = resolveWorkingDir(flags, projectId, log);
856
+ if (!cwd) {
857
+ process.exitCode = 1;
858
+ return;
859
+ }
860
+ // One agent at a time - wait for any in-flight session to finish.
861
+ const lock = await (0, lock_1.acquireLock)(agentId, 'build', { projectId }, {
862
+ onWaiting: (busy) => log(`[lock] agent ${agentId} is busy (pid ${busy.pid}, since ${busy.startedAt}, ${busy.kind}) - waiting for it to finish...`),
863
+ });
864
+ if (!lock) {
865
+ console.error(`[lock] gave up waiting for agent ${agentId} - try again later`);
866
+ process.exitCode = 1;
867
+ return;
868
+ }
869
+ let exitCode = 0;
870
+ const sessionId = stream.sessionId;
871
+ stream.connect((process.env.TEAMSHARE_WS_URL ?? WS_DEFAULT).replace(/\/$/, '') + '/agents', apiKey);
872
+ const ctx = { api, agentId, projectId, cwd, selfMode, harness, settings, flags, stream, sessionId, log };
873
+ try {
874
+ applyOpenCodeKey(agentId, log, settings);
875
+ exitCode = await runBuildLoop(ctx);
876
+ }
877
+ catch (err) {
878
+ log(`[build] fatal: ${(0, config_1.msg)(err)}`, 'stderr');
879
+ exitCode = 1;
880
+ }
881
+ finally {
882
+ clearStopFile(agentId);
883
+ (0, lock_1.releaseLock)(agentId);
884
+ await stream.end(exitCode);
885
+ }
886
+ }
887
+ /** Prints the current build queue without working anything (--dry-run). */
888
+ async function cmdBuildDryRun(api, projectId, log) {
889
+ const queue = await api.getBuildQueue(projectId);
890
+ console.log(`Build queue for project ${queue.project.id} (${queue.project.name})`);
891
+ console.log(`build agent: ${queue.buildAgent ? queue.buildAgent.name : '(none assigned)'}`);
892
+ console.log('');
893
+ if (queue.pending.length === 0) {
894
+ console.log('pending: none (mark tasks ready-for-dev to add them to the queue)');
895
+ }
896
+ else {
897
+ console.log(`pending (${queue.pending.length}):`);
898
+ for (const item of queue.pending) {
899
+ const t = item.task;
900
+ console.log(` #${item.position} ${t.title} (${t.priority}, ${t.status})`);
901
+ }
902
+ }
903
+ if (queue.blocked.length === 0) {
904
+ console.log('blocked: none');
905
+ }
906
+ else {
907
+ console.log(`blocked (${queue.blocked.length}) - waiting for answers:`);
908
+ for (const item of queue.blocked) {
909
+ console.log(` - ${item.task.title}: ${item.question.body}`);
910
+ }
911
+ }
912
+ }
913
+ /** Plans the build queue for a goal: LLM creates ready tasks in order. */
914
+ async function draftBuildPlan(ctx) {
915
+ const { api, agentId, projectId, settings, flags, log } = ctx;
916
+ const llmApiKey = sessionLlmApiKey(settings);
917
+ if (!llmApiKey) {
918
+ log('--draft requires a provider API key (set it on the agent in TeamShare, or LLM_API_KEY env)', 'stderr');
919
+ return '';
920
+ }
921
+ const endpoint = llmEndpoint(flags, settings);
922
+ log(`[draft] model ${endpoint.model} @ ${endpoint.baseUrl}`);
923
+ const goal = flags.get('draft') ?? '';
924
+ const project = await api.getProject(projectId).catch(() => null);
925
+ const prompt = `Plan the work for this goal and create the build queue for it NOW:\n\n` +
926
+ `"${goal}"\n\n` +
927
+ `Break the goal into 3-8 concrete, independently completable tasks. For each task:\n` +
928
+ `- create_task with readyForDev: true and a clear one-line title\n` +
929
+ `- set increasing sortOrder (1, 2, 3, ...) so the build works them strictly in order\n` +
930
+ `- assign it to yourself with assigneeId "me"\n` +
931
+ `- order dependencies first (a task that unblocks others comes before them)\n` +
932
+ `After creating them, reply with the ordered task list and the total count.`;
933
+ const reply = await (0, llm_1.runProjectSession)(api, projectId, prompt, {
934
+ baseUrl: endpoint.baseUrl,
935
+ apiKey: llmApiKey,
936
+ model: endpoint.model,
937
+ agentName: agentId,
938
+ systemPrompt: `You are the build planner for project ${project ? `"${project.name}" (${projectId})` : projectId} in TeamShare build mode.\n` +
939
+ 'You turn a goal into an ordered, executable build queue. Create each task immediately with create_task ' +
940
+ '(readyForDev: true, sortOrder increasing, assigneeId "me"). Do NOT ask for confirmation first - the plan IS the deliverable.',
941
+ temperature: 0.2,
942
+ agentUserId: settings.userId ?? undefined,
943
+ }, project?.name);
944
+ log(`[draft] plan reply: ${reply}`);
945
+ return reply;
946
+ }
947
+ /**
948
+ * The build run loop: pending -> work -> done; failure -> skip + ask +
949
+ * continue; answered questions -> resume; empty queue -> finish. Progress is
950
+ * pushed to the backend after every transition. Exits 0 on a clean finish.
951
+ */
952
+ async function runBuildLoop(ctx) {
953
+ const { api, agentId, projectId, cwd, selfMode, harness, settings, flags, stream, sessionId, log } = ctx;
954
+ const once = flags.get('once') === 'true';
955
+ const onFail = flags.get('on-fail') ?? 'ask';
956
+ const blockedWaitMin = Math.max(1, Number(flags.get('blocked-wait')) || 30);
957
+ let state = loadBuildState(agentId, projectId);
958
+ const queue = await api.getBuildQueue(projectId);
959
+ // Crash recovery: a fresh process re-seeds skipped tasks from blocked ones.
960
+ if (state.skipped.length === 0 && queue.blocked.length > 0) {
961
+ state.skipped = queue.blocked.map((b) => ({
962
+ taskId: b.task.id,
963
+ title: b.task.title,
964
+ askedAt: b.question.askedAt,
965
+ }));
966
+ saveBuildState(agentId, projectId, state);
967
+ }
968
+ log(`[build] queue: ${queue.pending.length} pending, ${queue.blocked.length} blocked, ${state.done} done, ${state.skipped.length} waiting`);
969
+ const push = (body) => {
970
+ const fresh = queue.pending.length + queue.blocked.length;
971
+ void api
972
+ .pushBuildProgress(agentId, {
973
+ projectId,
974
+ status: body.status,
975
+ total: state.done + fresh,
976
+ done: state.done,
977
+ remaining: fresh,
978
+ currentTaskId: body.currentTaskId,
979
+ currentTitle: body.currentTitle,
980
+ skippedTaskId: body.skippedTaskId,
981
+ })
982
+ .catch((err) => log(`[build] progress push failed: ${(0, config_1.msg)(err)}`, 'stderr'));
983
+ };
984
+ // Live heartbeat: keeps the agent marked working during long sessions.
985
+ let currentTaskId;
986
+ const heartbeatTimer = setInterval(() => {
987
+ void (0, brief_1.reportStatus)(api, agentId, 'working', currentTaskId, sessionId);
988
+ }, 60_000);
989
+ await (0, brief_1.reportStatus)(api, agentId, 'working', currentTaskId, sessionId);
990
+ try {
991
+ push({ status: 'running' });
992
+ for (;;) {
993
+ if (hasStopFile(agentId)) {
994
+ clearStopFile(agentId);
995
+ log('[build] stop requested - wrapping up');
996
+ push({ status: 'stopped' });
997
+ return 0;
998
+ }
999
+ const q = await api.getBuildQueue(projectId);
1000
+ // 1) Strict order: work the first pending ready-for-dev task.
1001
+ const next = q.pending[0];
1002
+ if (next) {
1003
+ currentTaskId = next.task.id;
1004
+ push({ status: 'running', currentTaskId: next.task.id, currentTitle: next.task.title });
1005
+ log(`[build] task ${next.position}/${q.pending.length + q.blocked.length + state.skipped.length}: "${next.task.title}"`);
1006
+ const outcome = await workBuildTask(ctx, next.task);
1007
+ if (outcome === 'done') {
1008
+ state.done += 1;
1009
+ saveBuildState(agentId, projectId, state);
1010
+ log(`[build] task completed (${state.done} done)`);
1011
+ push({ status: 'running' });
1012
+ if (once) {
1013
+ log('[build] --once: stopping after the first task');
1014
+ push({ status: 'finished' });
1015
+ return 0;
1016
+ }
1017
+ continue;
1018
+ }
1019
+ // Failure: skip + ask (or stop the run).
1020
+ if (onFail === 'stop') {
1021
+ log(`[build] task "${next.task.title}" failed and --on-fail stop is set - stopping the run`, 'stderr');
1022
+ push({ status: 'stopped', skippedTaskId: next.task.id });
1023
+ return 0;
1024
+ }
1025
+ if (state.skipped.some((s) => s.taskId === next.task.id)) {
1026
+ log(`[build] task "${next.task.title}" failed again - re-asking`);
1027
+ }
1028
+ else {
1029
+ state.skipped.push({ taskId: next.task.id, title: next.task.title, askedAt: new Date().toISOString() });
1030
+ }
1031
+ saveBuildState(agentId, projectId, state);
1032
+ await askBuildQuestion(api, projectId, next.task, ctx.log);
1033
+ push({ status: 'running', skippedTaskId: next.task.id });
1034
+ if (once) {
1035
+ log('[build] --once: stopping after the first task (skipped)');
1036
+ push({ status: 'stopped' });
1037
+ return 0;
1038
+ }
1039
+ continue;
1040
+ }
1041
+ // 2) No pending work - resume the first skipped task whose question
1042
+ // has been answered (open question gone = human answered).
1043
+ let resumed = null;
1044
+ for (const s of state.skipped) {
1045
+ const qState = await api.getTaskQuestions(s.taskId).catch(() => ({ open: null }));
1046
+ if (!qState.open) {
1047
+ resumed = { taskId: s.taskId, title: s.title };
1048
+ break;
1049
+ }
1050
+ }
1051
+ if (resumed) {
1052
+ currentTaskId = resumed.taskId;
1053
+ log(`[build] resuming "${resumed.title}" (question answered)`);
1054
+ const task = await api.getTask(resumed.taskId).catch(() => null);
1055
+ if (task) {
1056
+ const outcome = await workBuildTask(ctx, task);
1057
+ if (outcome === 'done') {
1058
+ state.skipped = state.skipped.filter((s) => s.taskId !== resumed.taskId);
1059
+ state.done += 1;
1060
+ saveBuildState(agentId, projectId, state);
1061
+ log(`[build] resumed task completed (${state.done} done)`);
1062
+ push({ status: 'running' });
1063
+ }
1064
+ else if (!(await api.getTaskQuestions(resumed.taskId).catch(() => ({ open: null }))).open) {
1065
+ log(`[build] resumed task failed again - re-asking`);
1066
+ await askBuildQuestion(api, projectId, task, ctx.log);
1067
+ }
1068
+ }
1069
+ if (once) {
1070
+ log('[build] --once: stopping after the resumed task');
1071
+ push({ status: 'stopped' });
1072
+ return 0;
1073
+ }
1074
+ continue;
1075
+ }
1076
+ // 3) Nothing pending and skipped tasks are still waiting on answers.
1077
+ if (state.skipped.length > 0) {
1078
+ const deadline = Date.now() + blockedWaitMin * 60_000;
1079
+ log(`[build] ${state.skipped.length} task(s) blocked on answers - waiting up to ${blockedWaitMin} min`);
1080
+ push({ status: 'blocked' });
1081
+ while (Date.now() < deadline) {
1082
+ if (hasStopFile(agentId)) {
1083
+ clearStopFile(agentId);
1084
+ log('[build] stop requested while blocked - exiting');
1085
+ push({ status: 'stopped' });
1086
+ return 0;
1087
+ }
1088
+ const q2 = await api.getBuildQueue(projectId);
1089
+ if (q2.pending.length > 0)
1090
+ break; // new ready task - loop and work it
1091
+ let answered = false;
1092
+ for (const s of state.skipped) {
1093
+ const qState = await api.getTaskQuestions(s.taskId).catch(() => ({ open: null }));
1094
+ if (!qState.open) {
1095
+ answered = true;
1096
+ break;
1097
+ }
1098
+ }
1099
+ if (answered)
1100
+ break;
1101
+ await new Promise((r) => setTimeout(r, 15_000));
1102
+ }
1103
+ continue; // re-evaluate: pending, answered, or still blocked (loops back)
1104
+ }
1105
+ // 4) Queue empty and nothing waiting - the run is finished.
1106
+ push({ status: 'finished' });
1107
+ log(`[build] queue finished - ${state.done} task(s) completed`);
1108
+ clearBuildState(agentId, projectId);
1109
+ try {
1110
+ const channels = await api.listChatChannels(projectId);
1111
+ const ch = channels.find((c) => c.name === AGENT_QUESTIONS_CHANNEL);
1112
+ const target = ch ?? channels[0];
1113
+ if (target) {
1114
+ await api.postChatMessage(projectId, `Build run finished: ${state.done} task(s) completed from the ready-for-dev queue.`, target.id);
1115
+ }
1116
+ }
1117
+ catch (err) {
1118
+ log(`[build] summary message failed: ${(0, config_1.msg)(err)}`, 'stderr');
1119
+ }
1120
+ return 0;
1121
+ }
1122
+ }
1123
+ finally {
1124
+ clearInterval(heartbeatTimer);
1125
+ await (0, brief_1.reportStatus)(api, agentId, 'online', undefined, sessionId);
1126
+ }
1127
+ }
1128
+ /** Works ONE build task to completion; returns 'done' or 'failed'. */
1129
+ async function workBuildTask(ctx, task) {
1130
+ const { api, agentId, cwd, selfMode, harness, settings, flags, sessionId, log } = ctx;
1131
+ const taskId = task.id;
1132
+ ctx.stream.meta.taskId = taskId;
1133
+ let claimed = false;
1134
+ try {
1135
+ const claim = await api.claimTask(taskId);
1136
+ claimed = true;
1137
+ log(`[claim] task ${taskId} claimed - lease until ${claim.claimExpiresAt}`);
1138
+ }
1139
+ catch (err) {
1140
+ const e = err;
1141
+ log(`[claim] cannot work task ${taskId}: ${e.message ?? e.code}`, 'stderr');
1142
+ return 'failed';
1143
+ }
1144
+ await (0, brief_1.reportStatus)(api, agentId, 'working', taskId, sessionId);
1145
+ try {
1146
+ const brief = await (0, brief_1.fetchTaskBrief)(api, taskId);
1147
+ const briefPath = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `teamshare-build-${taskId}.md`);
1148
+ (0, node_fs_2.writeFileSync)(briefPath, brief, 'utf8');
1149
+ log(`[brief] written to ${briefPath} (${brief.length} chars)`);
1150
+ log('[brief] --- preview ---');
1151
+ for (const line of brief.split('\n').slice(0, 18))
1152
+ log(line);
1153
+ log('[brief] --- end preview ---');
1154
+ let summary = '';
1155
+ if (selfMode) {
1156
+ const llmApiKey = sessionLlmApiKey(settings);
1157
+ if (!llmApiKey) {
1158
+ log('--self requires a provider API key (set it on the agent in TeamShare, or LLM_API_KEY env)', 'stderr');
1159
+ return 'failed';
1160
+ }
1161
+ const endpoint = llmEndpoint(flags, settings);
1162
+ log(`[model] ${endpoint.model} @ ${endpoint.baseUrl}`);
1163
+ const result = await (0, llm_1.runLlmSession)(api, taskId, brief, {
1164
+ baseUrl: endpoint.baseUrl,
1165
+ apiKey: llmApiKey,
1166
+ model: endpoint.model,
1167
+ agentName: agentId,
1168
+ systemPrompt: systemPromptOf(flags, settings),
1169
+ temperature: temperatureOf(flags, settings),
1170
+ maxTokens: maxTokensOf(flags, settings),
1171
+ continue: flags.get('continue') === 'true',
1172
+ agentUserId: settings.userId ?? undefined,
1173
+ }, task.projectId);
1174
+ summary =
1175
+ result.summary ||
1176
+ `Agent run finished after ${result.iterations} iterations (${result.tokensUsed} tokens).`;
1177
+ log(`[session] done: ${result.finishedBy} | ${result.iterations} iterations | ${result.tokensUsed} tokens`);
1178
+ }
1179
+ else {
1180
+ const systemPrompt = systemPromptOf(flags, settings);
1181
+ const prompt = `Work on the TeamShare task described in ${briefPath} using the teamshare MCP tools. ` +
1182
+ `This task is part of a BUILD RUN - complete it fully before finishing. ` +
1183
+ `Break the task down first: create 3-8 subtasks with create_subtask, then check each off ` +
1184
+ `(update_subtask with done: true) as you complete it. ` +
1185
+ (settings.userId
1186
+ ? `Your TeamShare user id is ${settings.userId} - when a task or the brief asks you to assign a task to yourself, pass assigneeId "me" (resolves to your user id).\n`
1187
+ : '') +
1188
+ (systemPrompt ? `Follow these instructions: ${systemPrompt}\n\n` : '') +
1189
+ `Keep the human informed with progress comments on the task. When done, post a detailed ` +
1190
+ `summary comment (mention the subtask progress) and move the task to in_review (or resolved if you are confident).`;
1191
+ const exitCode = await spawnHarness(harness, prompt, log, {
1192
+ model: modelOf(flags, settings),
1193
+ temperature: temperatureOf(flags, settings),
1194
+ cwd,
1195
+ timeoutMin: sessionTimeoutMin(flags),
1196
+ title: `TeamShare build task ${taskId} (agent ${agentId})`,
1197
+ agentKey: api.apiKey,
1198
+ apiBaseUrl: api.baseUrl,
1199
+ });
1200
+ summary = exitCode === 0 ? `Harness (${harness}) finished the session - see the task comments.` : `Harness (${harness}) exited with code ${exitCode} - see the task comments.`;
1201
+ }
1202
+ if (flags.get('no-report') !== 'true')
1203
+ await (0, brief_1.postClosingComment)(api, taskId, summary);
1204
+ const final = await (0, brief_1.waitForTaskCompletion)(api, taskId, 15_000, 15 * 60_000, (s) => log(`[status] ${s}`));
1205
+ log(`[done] task ${taskId} final status: ${final.status}`);
1206
+ return DONE_STATUSES.has(final.status) ? 'done' : 'failed';
1207
+ }
1208
+ catch (err) {
1209
+ log(`[build] task ${taskId} errored: ${(0, config_1.msg)(err)}`, 'stderr');
1210
+ return 'failed';
1211
+ }
1212
+ finally {
1213
+ if (claimed) {
1214
+ await api.releaseTask(taskId).catch(() => undefined);
1215
+ log(`[claim] task ${taskId} released`);
1216
+ }
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Asks the team about a failed build task: a [question] comment on the task
1221
+ * plus a message in the project's #agent-questions channel (both surfaces
1222
+ * count as answerable per the backend). Skips when a question is already open.
1223
+ */
1224
+ async function askBuildQuestion(api, projectId, task, log) {
1225
+ try {
1226
+ const current = await api.getTaskQuestions(task.id);
1227
+ if (current.open) {
1228
+ log(`[build] question already open for "${task.title}" - not re-asking`);
1229
+ return;
1230
+ }
1231
+ }
1232
+ catch {
1233
+ /* fall through - ask anyway */
1234
+ }
1235
+ const body = `[question] Build could not complete task "${task.title}". ` +
1236
+ `The session output is in the task comments - check them and reply here (or in #agent-questions) ` +
1237
+ `with what the agent should do next. The build resumes this task automatically once you answer.`;
1238
+ try {
1239
+ await api.addComment({ taskId: task.id, body });
1240
+ log(`[build] asked about "${task.title}" (task comment)`);
1241
+ }
1242
+ catch (err) {
1243
+ log(`[build] question comment failed: ${(0, config_1.msg)(err)}`, 'stderr');
1244
+ }
1245
+ try {
1246
+ const channels = await api.listChatChannels(projectId);
1247
+ let channelId = channels.find((c) => c.name === AGENT_QUESTIONS_CHANNEL)?.id;
1248
+ if (!channelId) {
1249
+ const created = await api
1250
+ .createChatChannel(projectId, AGENT_QUESTIONS_CHANNEL, 'Agent build questions - answering resumes the build')
1251
+ .catch(() => null);
1252
+ channelId = created?.id;
1253
+ }
1254
+ if (channelId) {
1255
+ await api.postChatMessage(projectId, `Agent needs input on build task "${task.title}": check the task comments and reply - the build resumes automatically.`, channelId);
1256
+ }
1257
+ }
1258
+ catch (err) {
1259
+ log(`[build] #${AGENT_QUESTIONS_CHANNEL} message failed: ${(0, config_1.msg)(err)}`, 'stderr');
1260
+ }
1261
+ }
417
1262
  /** Picks the agent's next task from the inbox; returns its id or null. */
418
1263
  async function pickNextTask(api, agentId, log) {
419
1264
  const inbox = await api.inbox(agentId);
@@ -454,25 +1299,66 @@ function spawnHarness(harness, prompt, log, settings = {}) {
454
1299
  }
455
1300
  log(`[harness] launching ${chosen}...`);
456
1301
  const cmd = chosen === 'opencode' ? 'opencode' : 'claude';
457
- const args = chosen === 'opencode'
458
- ? [
459
- 'run',
460
- ...(settings.model ? ['--model', settings.model] : []),
461
- ...(settings.temperature !== undefined
462
- ? ['--temperature', String(settings.temperature)]
463
- : []),
464
- prompt,
465
- ]
466
- : ['-p', prompt];
1302
+ const args = [];
1303
+ if (chosen === 'opencode') {
1304
+ args.push('run');
1305
+ // Non-interactive runs must not stall on permission prompts: without
1306
+ // --auto, opencode auto-REJECTS any unapproved tool call (MCP tools
1307
+ // like create_subtask, reads outside the working folder) and then sits
1308
+ // idle at the prompt forever. The working folder is guard-pinned and
1309
+ // the run is kill-switched (--session-timeout), so --auto is safe here.
1310
+ args.push('--auto');
1311
+ // opencode's --model expects `provider/model` ids; TeamShare catalog
1312
+ // ids (`provider:model`) are for the --self loop - only pass ids that
1313
+ // opencode itself understands (e.g. opencode-go/deepseek-v4-flash) and
1314
+ // never pass flags opencode does not support (--temperature) - both
1315
+ // made opencode print its help and exit 1 on Windows.
1316
+ if (settings.model && settings.model.includes('/')) {
1317
+ args.push('--model', settings.model);
1318
+ }
1319
+ // Spend attribution: every bridge session records a queryable title so
1320
+ // `teamshare-agent usage` can split agent work from the user's windows.
1321
+ if (settings.title) {
1322
+ args.push('--title', settings.title);
1323
+ }
1324
+ args.push(prompt);
1325
+ }
1326
+ else {
1327
+ args.push('-p', prompt);
1328
+ }
467
1329
  const env = chosen === 'claude' && settings.model
468
1330
  ? { ...process.env, ANTHROPIC_MODEL: settings.model }
469
1331
  : undefined;
1332
+ // Pin the opencode teamshare MCP server to THIS agent's key: the user's
1333
+ // global opencode config carries one shared key (BridgeAgent), so without
1334
+ // this override every agent would act as BridgeAgent inside the harness.
1335
+ let mcpOverride = null;
1336
+ if (chosen === 'opencode' && settings.agentKey) {
1337
+ mcpOverride = (0, harness_1.writeOpencodeMcpConfig)(settings.agentKey, settings.apiBaseUrl ?? process.env.TEAMSHARE_API_URL ?? 'http://localhost:4000/api');
1338
+ }
1339
+ // Windows: spawn a single fully-quoted command through the shell.
1340
+ // `spawn(exe, args, { shell: true })` joins the args WITHOUT quoting, so
1341
+ // cmd word-splits the prompt and opencode prints help + exits 1.
1342
+ const quote = (a) => /[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a;
1343
+ const command = [cmd, ...args.map(quote)].join(' ');
470
1344
  return new Promise((resolve) => {
471
- const child = (0, node_child_process_1.spawn)(cmd, args, {
472
- shell: process.platform === 'win32',
1345
+ const child = (0, node_child_process_1.spawn)(command, {
1346
+ shell: true,
473
1347
  stdio: ['ignore', 'pipe', 'pipe'],
474
- env,
1348
+ env: mcpOverride
1349
+ ? { ...(env ?? process.env), OPENCODE_CONFIG: mcpOverride.file }
1350
+ : env,
1351
+ cwd: settings.cwd,
475
1352
  });
1353
+ // Kill-switch: a runaway harness must never burn usage forever. The
1354
+ // cap defaults to 60 minutes and is settable per run (--session-timeout)
1355
+ // or globally (TEAMSHARE_SESSION_TIMEOUT_MIN).
1356
+ const timeoutMs = Math.max(1, settings.timeoutMin ?? 60) * 60_000;
1357
+ const killTimer = setTimeout(() => {
1358
+ log(`[harness] ${cmd} killed after ${settings.timeoutMin ?? 60} min (session timeout cap)`, 'stderr');
1359
+ child.kill();
1360
+ resolve(124);
1361
+ }, timeoutMs);
476
1362
  const tee = (chunk, level) => {
477
1363
  for (const line of chunk.toString('utf8').split(/\r?\n/)) {
478
1364
  if (line.trim())
@@ -482,10 +1368,14 @@ function spawnHarness(harness, prompt, log, settings = {}) {
482
1368
  child.stdout?.on('data', (c) => tee(c, 'stdout'));
483
1369
  child.stderr?.on('data', (c) => tee(c, 'stderr'));
484
1370
  child.on('error', (err) => {
1371
+ clearTimeout(killTimer);
1372
+ mcpOverride?.cleanup();
485
1373
  log(`[harness] ${cmd} failed to start: ${(0, config_1.msg)(err)}`, 'stderr');
486
1374
  resolve(1);
487
1375
  });
488
1376
  child.on('close', (code) => {
1377
+ clearTimeout(killTimer);
1378
+ mcpOverride?.cleanup();
489
1379
  if (code !== 0)
490
1380
  log(`[harness] ${cmd} exited with code ${code ?? 'error'}`, 'stderr');
491
1381
  resolve(code ?? 1);
@@ -497,6 +1387,140 @@ function hasBin(bin) {
497
1387
  const res = spawnSync(bin, ['--version'], { stdio: 'ignore', shell: process.platform === 'win32' });
498
1388
  return res.status === 0;
499
1389
  }
1390
+ async function cmdSkills(action) {
1391
+ if (action === 'list') {
1392
+ const { available, installed, target } = (0, skills_1.listSkills)();
1393
+ console.log(`Skills directory: ${target}`);
1394
+ console.log(`Shipped with this bridge: ${available.join(', ') || '(none found)'}`);
1395
+ console.log(`Installed: ${installed.join(', ') || '(none - run "teamshare-agent skills install")'}`);
1396
+ return;
1397
+ }
1398
+ if (action === 'install') {
1399
+ const { copied, target } = (0, skills_1.installSkills)();
1400
+ console.log(`[skills] installed ${copied.join(', ') || '(nothing to copy)'} -> ${target}`);
1401
+ return;
1402
+ }
1403
+ if (action === 'uninstall') {
1404
+ const { removed, target } = (0, skills_1.uninstallSkills)();
1405
+ console.log(`[skills] removed ${removed.join(', ') || '(nothing installed)'} from ${target}`);
1406
+ return;
1407
+ }
1408
+ console.error('skills requires one of: install | list | uninstall');
1409
+ process.exitCode = 2;
1410
+ }
1411
+ /**
1412
+ * `key` - manage the LLM provider key agents use to run their harness.
1413
+ * Without --agent the key is the GLOBAL default for every agent; with
1414
+ * --agent it overrides that agent only. Keys are never printed in full.
1415
+ */
1416
+ async function cmdMcp(flags, action) {
1417
+ const action2 = action ?? 'status';
1418
+ const config = (0, config_1.loadConfig)();
1419
+ const apiKey = flags.get('key') ?? config.agents[0]?.apiKey ?? '';
1420
+ if (action2 === 'wire') {
1421
+ if (!apiKey) {
1422
+ console.error('mcp wire requires --key <agentApiKey> or a configured agent');
1423
+ process.exitCode = 2;
1424
+ return;
1425
+ }
1426
+ const wired = (0, config_1.wireOpencodeMcp)(apiKey);
1427
+ console.log(wired
1428
+ ? '[mcp] wired TeamShare MCP into opencode.jsonc — agent now has 21 tools'
1429
+ : '[mcp] TeamShare MCP already wired — skipping');
1430
+ return;
1431
+ }
1432
+ if (action2 === 'unwire') {
1433
+ const removed = (0, config_1.unwireOpencodeMcp)();
1434
+ console.log(removed
1435
+ ? '[mcp] removed TeamShare MCP from opencode.jsonc'
1436
+ : '[mcp] TeamShare MCP not found in opencode.jsonc — nothing to remove');
1437
+ return;
1438
+ }
1439
+ // status
1440
+ const status = (0, config_1.mcpStatus)();
1441
+ console.log('TeamShare MCP in opencode:');
1442
+ console.log(status.configPath
1443
+ ? ` config : ${status.configPath}`
1444
+ : ' config : opencode.jsonc not found');
1445
+ console.log(status.wired
1446
+ ? ` status: WIRED — agent has 21 TeamShare tools (create_task, list_tasks, search, etc.)`
1447
+ : ' status: NOT WIRED — agent has no TeamShare tools in opencode sessions');
1448
+ if (status.wired) {
1449
+ const entry = status.entry;
1450
+ console.log(` url : ${entry?.url ?? 'n/a'}`);
1451
+ const key = entry?.headers?.['X-Api-Key'] ?? '';
1452
+ console.log(` key : ${key ? key.slice(0, 8) + '...' : 'n/a'}`);
1453
+ }
1454
+ }
1455
+ async function cmdKey(flags, action) {
1456
+ const agentId = flags.get('agent');
1457
+ const chatFlag = flags.get('chat') === 'true';
1458
+ const action2 = action ?? 'show';
1459
+ if (action2 === 'set') {
1460
+ const key = flags.get('key') ?? flags.get('set');
1461
+ if (!key) {
1462
+ console.error('key set requires --key <providerApiKey> [--agent <agentId>] [--chat]');
1463
+ process.exitCode = 2;
1464
+ return;
1465
+ }
1466
+ try {
1467
+ if (chatFlag) {
1468
+ (0, config_1.setChatKey)(key);
1469
+ console.log(`[key] stored chat model key (${key.slice(0, 8)}...) - chat replies use deepseek:deepseek-chat at temp 0.1`);
1470
+ }
1471
+ else {
1472
+ (0, config_1.setOpenCodeKey)(key, agentId ?? undefined);
1473
+ console.log(agentId
1474
+ ? `[key] stored agent-specific LLM key for agent ${agentId} (${key.slice(0, 8)}...)`
1475
+ : `[key] stored global agent LLM key (${key.slice(0, 8)}...) - all agents use it unless overridden`);
1476
+ }
1477
+ }
1478
+ catch (err) {
1479
+ console.error(`[key] ${(0, config_1.msg)(err)}`);
1480
+ process.exitCode = 1;
1481
+ }
1482
+ return;
1483
+ }
1484
+ if (action2 === 'clear') {
1485
+ if (chatFlag) {
1486
+ (0, config_1.setChatKey)(null);
1487
+ console.log('[key] cleared the chat model key (chat falls back to LLM_API_KEY env)');
1488
+ }
1489
+ else {
1490
+ (0, config_1.setOpenCodeKey)(null, agentId ?? undefined);
1491
+ console.log(agentId
1492
+ ? `[key] cleared agent-specific LLM key for ${agentId} (falls back to the global key or your default credentials)`
1493
+ : '[key] cleared the global agent LLM key (agents fall back to your default credentials)');
1494
+ }
1495
+ return;
1496
+ }
1497
+ // show
1498
+ const config = (0, config_1.loadConfig)();
1499
+ const global = config.opencodeKey;
1500
+ console.log('LLM provider keys for agent harness runs:');
1501
+ console.log(global
1502
+ ? ` global : set (${global.slice(0, 8)}...) - all agents unless overridden`
1503
+ : ' global : not set - agents fall back to your default credentials');
1504
+ console.log(process.env.OPENCODE_API_KEY
1505
+ ? ' env : OPENCODE_API_KEY set (wins over config)'
1506
+ : ' env : not set');
1507
+ for (const agent of config.agents) {
1508
+ console.log(agent.opencodeKey
1509
+ ? ` ${agent.agentId} : agent-specific (${agent.opencodeKey.slice(0, 8)}...)`
1510
+ : ` ${agent.agentId} : using global/default`);
1511
+ }
1512
+ // chat key
1513
+ const chatKeyVal = config.chatKey;
1514
+ console.log('\nChat model key (chat replies — deepseek:deepseek-chat at temp 0.1):');
1515
+ console.log(chatKeyVal
1516
+ ? ` config : set (${chatKeyVal.slice(0, 8)}...)`
1517
+ : ' config : not set');
1518
+ console.log(process.env.CHAT_LLM_API_KEY
1519
+ ? ' env CHAT_LLM_API_KEY : set (wins over config)'
1520
+ : process.env.LLM_API_KEY
1521
+ ? ' env LLM_API_KEY : set (fallback)'
1522
+ : ' env : not set');
1523
+ }
500
1524
  async function main() {
501
1525
  const { flags, positionals } = parseArgs(process.argv.slice(2));
502
1526
  if (flags.has('help') || positionals.length === 0) {
@@ -507,6 +1531,10 @@ async function main() {
507
1531
  try {
508
1532
  if (command === 'connect')
509
1533
  await cmdConnect(flags);
1534
+ else if (command === 'link')
1535
+ await cmdLink(flags);
1536
+ else if (command === 'unlink')
1537
+ await cmdUnlink(flags);
510
1538
  else if (command === 'run')
511
1539
  await cmdRun(flags);
512
1540
  else if (command === 'continue') {
@@ -523,6 +1551,16 @@ async function main() {
523
1551
  await cmdChat(flags);
524
1552
  else if (command === 'doc')
525
1553
  await cmdDoc(flags);
1554
+ else if (command === 'build')
1555
+ await cmdBuild(flags);
1556
+ else if (command === 'usage')
1557
+ await cmdUsage(flags);
1558
+ else if (command === 'skills')
1559
+ await cmdSkills(positionals[1]);
1560
+ else if (command === 'key')
1561
+ await cmdKey(flags, positionals[1]);
1562
+ else if (command === 'mcp')
1563
+ await cmdMcp(flags, positionals[1]);
526
1564
  else {
527
1565
  console.error(`Unknown command: ${command}`);
528
1566
  console.log(usage());