@aexol/spectral 0.9.55 → 0.9.59

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 (95) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +43 -0
  3. package/dist/agent/index.d.ts +6 -1
  4. package/dist/agent/index.d.ts.map +1 -1
  5. package/dist/agent/index.js +133 -14
  6. package/dist/commands/login.d.ts +1 -0
  7. package/dist/commands/login.d.ts.map +1 -1
  8. package/dist/commands/login.js +12 -0
  9. package/dist/extensions/aexol-mcp.d.ts.map +1 -1
  10. package/dist/extensions/aexol-mcp.js +42 -52
  11. package/dist/extensions/kanban-bridge.d.ts.map +1 -1
  12. package/dist/extensions/kanban-bridge.js +126 -27
  13. package/dist/extensions/web/index.d.ts +10 -0
  14. package/dist/extensions/web/index.d.ts.map +1 -0
  15. package/dist/extensions/web/index.js +11 -0
  16. package/dist/extensions/web/tools/fetch.d.ts +3 -0
  17. package/dist/extensions/web/tools/fetch.d.ts.map +1 -0
  18. package/dist/extensions/web/tools/fetch.js +80 -0
  19. package/dist/extensions/web/utils/fetcher.d.ts +34 -0
  20. package/dist/extensions/web/utils/fetcher.d.ts.map +1 -0
  21. package/dist/extensions/web/utils/fetcher.js +289 -0
  22. package/dist/extensions/web/utils/parser.d.ts +15 -0
  23. package/dist/extensions/web/utils/parser.d.ts.map +1 -0
  24. package/dist/extensions/web/utils/parser.js +205 -0
  25. package/dist/memory/branch.d.ts.map +1 -1
  26. package/dist/memory/branch.js +16 -8
  27. package/dist/memory/commands/status.d.ts.map +1 -1
  28. package/dist/memory/commands/status.js +3 -2
  29. package/dist/memory/config.d.ts +12 -0
  30. package/dist/memory/config.d.ts.map +1 -1
  31. package/dist/memory/config.js +21 -2
  32. package/dist/memory/hooks/compaction-trigger.d.ts.map +1 -1
  33. package/dist/memory/hooks/compaction-trigger.js +19 -3
  34. package/dist/memory/index.d.ts.map +1 -1
  35. package/dist/memory/index.js +2 -0
  36. package/dist/memory/tool-output-compressor.d.ts +14 -20
  37. package/dist/memory/tool-output-compressor.d.ts.map +1 -1
  38. package/dist/memory/tool-output-compressor.js +391 -137
  39. package/dist/memory/tools/compact-context.d.ts +5 -0
  40. package/dist/memory/tools/compact-context.d.ts.map +1 -0
  41. package/dist/memory/tools/compact-context.js +772 -0
  42. package/dist/relay/dispatcher.d.ts +1 -1
  43. package/dist/relay/dispatcher.d.ts.map +1 -1
  44. package/dist/relay/dispatcher.js +73 -1
  45. package/dist/sdk/coding-agent/core/agent-session.d.ts +56 -3
  46. package/dist/sdk/coding-agent/core/agent-session.d.ts.map +1 -1
  47. package/dist/sdk/coding-agent/core/agent-session.js +408 -210
  48. package/dist/sdk/coding-agent/core/compaction/compaction.d.ts +57 -7
  49. package/dist/sdk/coding-agent/core/compaction/compaction.d.ts.map +1 -1
  50. package/dist/sdk/coding-agent/core/compaction/compaction.js +146 -27
  51. package/dist/sdk/coding-agent/core/compaction/dcp-lite.d.ts +24 -0
  52. package/dist/sdk/coding-agent/core/compaction/dcp-lite.d.ts.map +1 -0
  53. package/dist/sdk/coding-agent/core/compaction/dcp-lite.js +411 -0
  54. package/dist/sdk/coding-agent/core/compaction/index.d.ts +2 -0
  55. package/dist/sdk/coding-agent/core/compaction/index.d.ts.map +1 -1
  56. package/dist/sdk/coding-agent/core/compaction/index.js +2 -0
  57. package/dist/sdk/coding-agent/core/compaction/policy.d.ts +71 -0
  58. package/dist/sdk/coding-agent/core/compaction/policy.d.ts.map +1 -0
  59. package/dist/sdk/coding-agent/core/compaction/policy.js +147 -0
  60. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  61. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +9 -0
  62. package/dist/sdk/coding-agent/core/extensions/types.d.ts +17 -0
  63. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  64. package/dist/sdk/coding-agent/core/settings-manager.d.ts +4 -10
  65. package/dist/sdk/coding-agent/core/settings-manager.d.ts.map +1 -1
  66. package/dist/sdk/coding-agent/core/settings-manager.js +17 -17
  67. package/dist/sdk/coding-agent/index.d.ts +1 -1
  68. package/dist/sdk/coding-agent/index.d.ts.map +1 -1
  69. package/dist/server/agent-bridge.d.ts +12 -5
  70. package/dist/server/agent-bridge.d.ts.map +1 -1
  71. package/dist/server/agent-bridge.js +65 -17
  72. package/dist/server/handlers/paths-home.d.ts +11 -0
  73. package/dist/server/handlers/paths-home.d.ts.map +1 -0
  74. package/dist/server/handlers/paths-home.js +10 -0
  75. package/dist/server/handlers/paths-list.d.ts +24 -0
  76. package/dist/server/handlers/paths-list.d.ts.map +1 -0
  77. package/dist/server/handlers/paths-list.js +56 -0
  78. package/dist/server/handlers/paths-pick-directory.d.ts.map +1 -1
  79. package/dist/server/handlers/paths-pick-directory.js +2 -1
  80. package/dist/server/handlers/projects.js +4 -4
  81. package/dist/server/handlers/sessions.d.ts +11 -1
  82. package/dist/server/handlers/sessions.d.ts.map +1 -1
  83. package/dist/server/handlers/sessions.js +22 -2
  84. package/dist/server/handlers/settings.d.ts +18 -1
  85. package/dist/server/handlers/settings.d.ts.map +1 -1
  86. package/dist/server/handlers/settings.js +63 -13
  87. package/dist/server/session-stream.d.ts +65 -37
  88. package/dist/server/session-stream.d.ts.map +1 -1
  89. package/dist/server/session-stream.js +525 -119
  90. package/dist/server/storage.d.ts +6 -0
  91. package/dist/server/storage.d.ts.map +1 -1
  92. package/dist/server/storage.js +72 -9
  93. package/dist/server/wire.d.ts +129 -2
  94. package/dist/server/wire.d.ts.map +1 -1
  95. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All notable changes to `@aexol/spectral` are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ### Added
9
+
10
+ - Added built-in `web_fetch` tool for fetching public web pages as text,
11
+ Markdown, or raw HTML. Includes SSRF protection, redirect handling, timeout
12
+ and size limits, plus unit and integration tests.
13
+
8
14
  ## [0.9.40] - 2026-06-22
9
15
 
10
16
  ## [0.9.39] - 2026-06-22
package/README.md CHANGED
@@ -151,6 +151,49 @@ sends it anywhere. Note that `spectral serve` does **not** use this file — it
151
151
  routes inference through the backend proxy instead (see
152
152
  [How inference is routed](#how-inference-is-routed)).
153
153
 
154
+ ## Web Fetch tool
155
+
156
+ Spectral ships a built-in `web_fetch` tool (enabled by default) for fetching
157
+ public web pages and returning their content as plain text, Markdown, or raw
158
+ HTML. It is intended for quickly reading static pages, documentation, GitHub
159
+ repositories, JSON endpoints, and other public HTTP/HTTPS resources.
160
+
161
+ ### Parameters
162
+
163
+ | Parameter | Type | Default | Description |
164
+ | --------- | -------- | ---------- | --------------------------------------------------------------------------- |
165
+ | `url` | string | required | Public URL to fetch. Must begin with `http://` or `https://`. |
166
+ | `format` | string | `markdown` | Output format: `text`, `markdown`, or `html`. |
167
+ | `timeout` | number | `30` | Request timeout in seconds, clamped to the range `1`–`120`. |
168
+
169
+ ### When to use `web_fetch`
170
+
171
+ Use `web_fetch` when you only need to **read** publicly available content:
172
+
173
+ - Static documentation sites and README pages.
174
+ - GitHub file, blob, or raw URLs.
175
+ - Public JSON/XML APIs (e.g., Reddit JSON endpoints).
176
+ - Simple marketing pages or blogs that render their main content server-side.
177
+
178
+ `web_fetch` is fast, lightweight, and does not require a browser. It applies
179
+ SSRF protection by rejecting `file://`, `ftp://`, `localhost`, and private IP
180
+ addresses, and it limits responses to 5 MB.
181
+
182
+ ### When to use the Browser extension instead
183
+
184
+ Use the full **Browser Automation** extension (also enabled by default) when the
185
+ page requires a real browser:
186
+
187
+ - JavaScript-rendered SPAs where the markup is produced in the browser.
188
+ - Pages that need interaction (clicks, scrolling, form input) before the
189
+ relevant content appears.
190
+ - Sites that block simple HTTP clients or require cookies / session state.
191
+ - Screenshots, visual verification, or network-traffic inspection.
192
+
193
+ The Browser extension controls a real Chromium instance via Playwright and can
194
+ do everything `web_fetch` can, plus interaction, screenshots, and JavaScript
195
+ evaluation.
196
+
154
197
  ## Multiple machines
155
198
 
156
199
  You can run `spectral serve` on as many machines as you like under one team —
@@ -18,14 +18,19 @@
18
18
  *
19
19
  * Subagents use an in-process `agentLoop()` for:
20
20
  * - Clean context isolation (fresh messages array)
21
- * - Combined system prompt (main agent + subagent-specific instructions)
21
+ * - Combined system prompt (shared/base prompt + subagent-specific instructions)
22
22
  * - Tool filtering (only the tools specified in agent config)
23
23
  * - Model selection (session model preferred, agent-configured as fallback)
24
24
  * - Signal propagation (abort passthrough)
25
25
  * - Access to observational memory via the recall tool
26
26
  */
27
27
  import { type ExtensionAPI } from "../sdk/coding-agent/index.js";
28
+ import { type AgentConfig } from "./agents.js";
29
+ import type { ServerEvent } from "../server/wire.ts";
30
+ export declare function stripInjectedPrimaryAgentPrompt(systemPrompt: string | undefined): string | undefined;
31
+ export declare function buildSubagentSystemPrompt(baseSystemPrompt: string | undefined, agent: Pick<AgentConfig, "name" | "systemPrompt">): string;
28
32
  export default function subagentExtension(ext: ExtensionAPI, opts?: {
29
33
  disabledAgents?: string[];
34
+ emitSubagentEvent?: (event: ServerEvent) => void;
30
35
  }): void;
31
36
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/agent/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAWH,OAAO,EACN,KAAK,YAAY,EAEjB,MAAM,8BAA8B,CAAC;AAsftC,MAAM,CAAC,OAAO,UAAU,iBAAiB,CAAC,GAAG,EAAE,YAAY,EAAE,IAAI,CAAC,EAAE;IAAE,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CA+UvG"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/agent/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAYH,OAAO,EACN,KAAK,YAAY,EAEjB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,EAAE,KAAK,WAAW,EAAmC,MAAM,aAAa,CAAC;AAEhF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAsFrD,wBAAgB,+BAA+B,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CASpG;AAED,wBAAgB,yBAAyB,CACxC,gBAAgB,EAAE,MAAM,GAAG,SAAS,EACpC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,cAAc,CAAC,GAC/C,MAAM,CAKR;AAkdD,MAAM,CAAC,OAAO,UAAU,iBAAiB,CACxC,GAAG,EAAE,YAAY,EACjB,IAAI,CAAC,EAAE;IAAE,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAA;CAAE,GACpF,IAAI,CAgZN"}
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Subagents use an in-process `agentLoop()` for:
20
20
  * - Clean context isolation (fresh messages array)
21
- * - Combined system prompt (main agent + subagent-specific instructions)
21
+ * - Combined system prompt (shared/base prompt + subagent-specific instructions)
22
22
  * - Tool filtering (only the tools specified in agent config)
23
23
  * - Model selection (session model preferred, agent-configured as fallback)
24
24
  * - Signal propagation (abort passthrough)
@@ -40,6 +40,8 @@ import { wrapToolDefinition } from "../sdk/coding-agent/core/tools/tool-definiti
40
40
  // ---------------------------------------------------------------------------
41
41
  const MAX_PARALLEL_TASKS = 8;
42
42
  const MAX_CONCURRENCY = 4;
43
+ const PRIMARY_AGENT_PROMPT_HEADER = "# Primary Agent:";
44
+ const PRIMARY_AGENT_PROMPT_DELIMITER = "\n\n---\n\n";
43
45
  // ---------------------------------------------------------------------------
44
46
  // Helpers — formatting
45
47
  // ---------------------------------------------------------------------------
@@ -73,6 +75,52 @@ function formatUsageStats(usage, model) {
73
75
  parts.push(model);
74
76
  return parts.join(" ");
75
77
  }
78
+ function isRecord(value) {
79
+ return value !== null && typeof value === "object" && !Array.isArray(value);
80
+ }
81
+ function textFromToolResultContent(value) {
82
+ if (!isRecord(value) || !Array.isArray(value.content))
83
+ return undefined;
84
+ const parts = [];
85
+ for (const part of value.content) {
86
+ if (isRecord(part) && part.type === "text" && typeof part.text === "string") {
87
+ parts.push(part.text);
88
+ }
89
+ }
90
+ const text = parts.join("").trim();
91
+ return text ? text : undefined;
92
+ }
93
+ function compactProgressMessage(value) {
94
+ const text = typeof value === "string" ? value.trim() : textFromToolResultContent(value);
95
+ if (text)
96
+ return text.length > 240 ? `${text.slice(0, 237)}...` : text;
97
+ if (isRecord(value) && typeof value.message === "string" && value.message.trim()) {
98
+ const message = value.message.trim();
99
+ return message.length > 240 ? `${message.slice(0, 237)}...` : message;
100
+ }
101
+ if (isRecord(value) && typeof value.error === "string" && value.error.trim()) {
102
+ const error = value.error.trim();
103
+ return error.length > 240 ? `${error.slice(0, 237)}...` : error;
104
+ }
105
+ return value === undefined ? undefined : "Tool progress updated";
106
+ }
107
+ export function stripInjectedPrimaryAgentPrompt(systemPrompt) {
108
+ if (!systemPrompt)
109
+ return systemPrompt;
110
+ if (!systemPrompt.startsWith(PRIMARY_AGENT_PROMPT_HEADER))
111
+ return systemPrompt;
112
+ const delimiterIndex = systemPrompt.indexOf(PRIMARY_AGENT_PROMPT_DELIMITER);
113
+ if (delimiterIndex === -1)
114
+ return undefined;
115
+ const basePrompt = systemPrompt.slice(delimiterIndex + PRIMARY_AGENT_PROMPT_DELIMITER.length);
116
+ return basePrompt.trim().length > 0 ? basePrompt : undefined;
117
+ }
118
+ export function buildSubagentSystemPrompt(baseSystemPrompt, agent) {
119
+ const sharedSystemPrompt = stripInjectedPrimaryAgentPrompt(baseSystemPrompt);
120
+ return sharedSystemPrompt
121
+ ? `${sharedSystemPrompt}\n\n---\n\nSubagent: ${agent.name}\n${agent.systemPrompt}`
122
+ : agent.systemPrompt;
123
+ }
76
124
  // ---------------------------------------------------------------------------
77
125
  // Helpers — message extraction
78
126
  // ---------------------------------------------------------------------------
@@ -176,7 +224,7 @@ async function mapWithConcurrencyLimit(items, concurrency, fn) {
176
224
  // ---------------------------------------------------------------------------
177
225
  // Core — run a single subagent in-process via agentLoop
178
226
  // ---------------------------------------------------------------------------
179
- async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails, ext, ctx) {
227
+ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails, parentToolCallId, onInternalEvent, ext, ctx) {
180
228
  const normalizedAgentName = agentName.replace(/^@/, "");
181
229
  const agent = agents.find((a) => a.name === normalizedAgentName);
182
230
  if (!agent) {
@@ -192,11 +240,9 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
192
240
  step,
193
241
  };
194
242
  }
195
- // Build combined system prompt: main agent's + subagent's
196
- const mainSystemPrompt = ctx.getSystemPrompt();
197
- const combinedPrompt = mainSystemPrompt
198
- ? `${mainSystemPrompt}\n\n---\n\nSubagent: ${agent.name}\n${agent.systemPrompt}`
199
- : agent.systemPrompt;
243
+ // Build combined system prompt without leaking an injected primary-agent
244
+ // block into the isolated subagent context.
245
+ const combinedPrompt = buildSubagentSystemPrompt(ctx.getSystemPrompt(), agent);
200
246
  // Apply system prompt mutation if enabled in settings (opt-in)
201
247
  let effectivePrompt = combinedPrompt;
202
248
  if (ctx.settingsManager) {
@@ -259,11 +305,19 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
259
305
  };
260
306
  // Accumulated streaming text from text_delta events (reset per assistant turn).
261
307
  let streamingText = "";
308
+ // Accumulated thinking deltas for the live subagent sheet. This stays outside
309
+ // final `SingleResult` so the public subagent tool_result contract is unchanged.
310
+ let thinkingText = "";
262
311
  const emitUpdate = () => {
263
312
  if (onUpdate) {
313
+ const liveResult = {
314
+ ...currentResult,
315
+ ...(streamingText ? { streamingText } : {}),
316
+ ...(thinkingText ? { thinkingText } : {}),
317
+ };
264
318
  onUpdate({
265
- content: [{ type: "text", text: getFinalOutput(currentResult.messages) || streamingText || "(running...)" }],
266
- details: makeDetails([currentResult]),
319
+ content: [{ type: "text", text: getFinalOutput(currentResult.messages) || streamingText || (thinkingText ? "(thinking...)" : "(running...)") }],
320
+ details: makeDetails([liveResult]),
267
321
  });
268
322
  }
269
323
  };
@@ -288,14 +342,19 @@ async function runSingleAgent(defaultCwd, agents, agentName, task, cwd, step, si
288
342
  hadError = true;
289
343
  break;
290
344
  }
345
+ onInternalEvent?.(event, parentToolCallId);
291
346
  if (event.type === "message_update" && event.message?.role === "assistant") {
292
- // Forward streaming text deltas so the UI can show the subagent's
293
- // response as it's generated, not just the final result.
347
+ // Forward streaming text/thinking deltas so the UI can show the
348
+ // subagent process as it's generated, not just the final result.
294
349
  const inner = event.assistantMessageEvent;
295
350
  if (inner?.type === "text_delta" && inner.delta) {
296
351
  streamingText += inner.delta;
297
352
  emitUpdate();
298
353
  }
354
+ else if (inner?.type === "thinking_delta" && inner.delta) {
355
+ thinkingText += inner.delta;
356
+ emitUpdate();
357
+ }
299
358
  continue;
300
359
  }
301
360
  if (event.type === "message_end" && event.message) {
@@ -451,6 +510,66 @@ export default function subagentExtension(ext, opts) {
451
510
  projectAgentsDir: discovery.projectAgentsDir,
452
511
  results,
453
512
  });
513
+ // Forward internal subagent tool lifecycle events to the bridge so the
514
+ // frontend sheet can show a live activity log. text_delta streaming
515
+ // still flows through the existing onUpdate/subagent_progress path.
516
+ const emitSubagentEvent = opts?.emitSubagentEvent;
517
+ const subagentStepStartedAt = new Map();
518
+ const subagentStepArgs = new Map();
519
+ const emitSubagentToolProgress = emitSubagentEvent
520
+ ? (event, parentToolCallId) => {
521
+ if (event.type === "tool_execution_start") {
522
+ const startedAt = Date.now();
523
+ subagentStepStartedAt.set(event.toolCallId, startedAt);
524
+ subagentStepArgs.set(event.toolCallId, event.args);
525
+ emitSubagentEvent({
526
+ type: "subagent_tool_progress",
527
+ toolCallId: parentToolCallId,
528
+ stepId: event.toolCallId,
529
+ name: event.toolName,
530
+ args: event.args,
531
+ status: "running",
532
+ startedAt,
533
+ });
534
+ }
535
+ else if (event.type === "tool_execution_update") {
536
+ const startedAt = subagentStepStartedAt.get(event.toolCallId) ?? Date.now();
537
+ subagentStepStartedAt.set(event.toolCallId, startedAt);
538
+ subagentStepArgs.set(event.toolCallId, event.args);
539
+ emitSubagentEvent({
540
+ type: "subagent_tool_progress",
541
+ toolCallId: parentToolCallId,
542
+ stepId: event.toolCallId,
543
+ name: event.toolName,
544
+ args: event.args,
545
+ status: "running",
546
+ partialResult: event.partialResult,
547
+ message: compactProgressMessage(event.partialResult),
548
+ startedAt,
549
+ });
550
+ }
551
+ else if (event.type === "tool_execution_end") {
552
+ const startedAt = subagentStepStartedAt.get(event.toolCallId) ?? Date.now();
553
+ const finishedAt = Date.now();
554
+ const args = subagentStepArgs.get(event.toolCallId);
555
+ subagentStepStartedAt.delete(event.toolCallId);
556
+ subagentStepArgs.delete(event.toolCallId);
557
+ emitSubagentEvent({
558
+ type: "subagent_tool_progress",
559
+ toolCallId: parentToolCallId,
560
+ stepId: event.toolCallId,
561
+ name: event.toolName,
562
+ args,
563
+ status: event.isError ? "error" : "success",
564
+ result: event.result,
565
+ isError: event.isError,
566
+ message: event.isError ? compactProgressMessage(event.result) : undefined,
567
+ startedAt,
568
+ finishedAt,
569
+ });
570
+ }
571
+ }
572
+ : undefined;
454
573
  if (modeCount !== 1) {
455
574
  const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
456
575
  return {
@@ -513,7 +632,7 @@ export default function subagentExtension(ext, opts) {
513
632
  }
514
633
  }
515
634
  : undefined;
516
- const result = await runSingleAgent(ctx.cwd, agents, step.agent, taskWithContext, step.cwd, i + 1, signal, chainUpdate, makeDetails("chain"), ext, ctx);
635
+ const result = await runSingleAgent(ctx.cwd, agents, step.agent, taskWithContext, step.cwd, i + 1, signal, chainUpdate, makeDetails("chain"), _toolCallId, emitSubagentToolProgress, ext, ctx);
517
636
  results.push(result);
518
637
  const isError = result.exitCode !== 0 ||
519
638
  result.stopReason === "error" ||
@@ -591,7 +710,7 @@ export default function subagentExtension(ext, opts) {
591
710
  allResults[index] = partial.details.results[0];
592
711
  emitParallelUpdate();
593
712
  }
594
- }, makeDetails("parallel"), ext, ctx);
713
+ }, makeDetails("parallel"), _toolCallId, emitSubagentToolProgress, ext, ctx);
595
714
  allResults[index] = result;
596
715
  emitParallelUpdate();
597
716
  return result;
@@ -614,7 +733,7 @@ export default function subagentExtension(ext, opts) {
614
733
  }
615
734
  // ── Single mode ─────────────────────────────────────────────
616
735
  if (params.agent && params.task) {
617
- const result = await runSingleAgent(ctx.cwd, agents, params.agent, params.task, params.cwd, undefined, signal, onUpdate, makeDetails("single"), ext, ctx);
736
+ const result = await runSingleAgent(ctx.cwd, agents, params.agent, params.task, params.cwd, undefined, signal, onUpdate, makeDetails("single"), _toolCallId, emitSubagentToolProgress, ext, ctx);
618
737
  const isError = result.exitCode !== 0 ||
619
738
  result.stopReason === "error" ||
620
739
  result.stopReason === "aborted";
@@ -9,6 +9,7 @@
9
9
  * 2b. OAuth: open browser for Aexol Studio authorization.
10
10
  * 3. Verify reachability + auth by calling tools/list against the URL.
11
11
  * 4. On success: persist to ~/.spectral/config.json (mode 0600).
12
+ * 5. Chain OAuth to also obtain a user JWT for Kanban/Studio features.
12
13
  *
13
14
  * Exits non-zero on any failure so shell scripts can detect it.
14
15
  *
@@ -1 +1 @@
1
- {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../src/commands/login.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAgBH,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAqCzF;AAED,wBAAsB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAyE9C"}
1
+ {"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../src/commands/login.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAiBH,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAqCzF;AAED,wBAAsB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAqF9C"}
@@ -9,6 +9,7 @@
9
9
  * 2b. OAuth: open browser for Aexol Studio authorization.
10
10
  * 3. Verify reachability + auth by calling tools/list against the URL.
11
11
  * 4. On success: persist to ~/.spectral/config.json (mode 0600).
12
+ * 5. Chain OAuth to also obtain a user JWT for Kanban/Studio features.
12
13
  *
13
14
  * Exits non-zero on any failure so shell scripts can detect it.
14
15
  *
@@ -20,6 +21,7 @@ import { password, select } from "@inquirer/prompts";
20
21
  import pc from "picocolors";
21
22
  import { TEAM_API_KEY_PREFIX, getApiUrl, getConfigFile, validateTeamApiKey, writeConfig, } from "../config.js";
22
23
  import { AexolMcpClient, AexolMcpError } from "../mcp-client.js";
24
+ import { runOAuthFlow } from "../auth-helper.js";
23
25
  /**
24
26
  * Pure login logic. Throws `Error` with one of these stable message prefixes:
25
27
  * - "Invalid API key prefix" — local validation failed
@@ -126,4 +128,14 @@ export async function runLogin() {
126
128
  }
127
129
  process.stdout.write(pc.green(`✓ Authenticated. Found ${result.toolCount} tools.\n`));
128
130
  process.stdout.write(pc.dim(`Saved credentials to ${getConfigFile()}\n`));
131
+ // Chain OAuth for Kanban/Studio features (non-fatal — MCP tools work without it)
132
+ process.stdout.write(pc.dim("\nAuthorizing for Aexol Studio features (Kanban)...\n"));
133
+ const oauthResult = await runOAuthFlow(apiUrl);
134
+ if (oauthResult.ok) {
135
+ process.stdout.write(pc.green("✓ CLI authorized for Studio/Kanban.\n"));
136
+ }
137
+ else {
138
+ process.stderr.write(pc.yellow(`⚠ OAuth authorization skipped (${oauthResult.reason}). Kanban/Studio features may not be available.\n`));
139
+ process.stderr.write(pc.dim(" Run `spectral login` and choose OAuth to set it up later.\n"));
140
+ }
129
141
  }
@@ -1 +1 @@
1
- {"version":3,"file":"aexol-mcp.d.ts","sourceRoot":"","sources":["../../src/extensions/aexol-mcp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoC,MAAM,8BAA8B,CAAC;AAsKnG,wBAA8B,iBAAiB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAuShF"}
1
+ {"version":3,"file":"aexol-mcp.d.ts","sourceRoot":"","sources":["../../src/extensions/aexol-mcp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoC,MAAM,8BAA8B,CAAC;AAiKnG,wBAA8B,iBAAiB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAsShF"}
@@ -80,22 +80,16 @@ function toLabel(name) {
80
80
  // ---------------------------------------------------------------------------
81
81
  // Studio binding – in-memory cache, refreshed on startup & by local tools
82
82
  // ---------------------------------------------------------------------------
83
- let currentBinding = null;
84
- let sessionCwd = null;
85
- let bindingMtimeMs = null;
83
+ /** Per-cwd binding cache — eliminates races when multiple sessions share a process. */
84
+ const bindingCache = new Map();
86
85
  /**
87
- * Re-read the Studio binding file when it changes on disk so that runtime
88
- * binding changes (e.g. via the /agent UI bind/unbind, or an external edit)
89
- * propagate to running agent sessions without requiring a CLI restart or a
90
- * new conversation. The AgentBridge's reloadProjectBindings() refreshes model
91
- * proxy routing, but the aexol-mcp extension's currentBinding is only read at
92
- * session_start otherwise. mtime caching keeps the common case (no change) at
93
- * a single cheap stat() per remote_* call.
86
+ * Per-cwd binding reader. Reads `.aexol/aexol.jsonc` under the given
87
+ * working directory with mtime caching. The cache key is the cwd itself,
88
+ * so concurrent sessions serving different projects never interfere.
94
89
  */
95
- async function refreshBindingFromDisk() {
96
- if (!sessionCwd)
97
- return;
98
- const bindingFile = join(sessionCwd, ".aexol", "aexol.jsonc");
90
+ async function refreshBindingForCwd(cwd) {
91
+ const bindingFile = join(cwd, ".aexol", "aexol.jsonc");
92
+ const cached = bindingCache.get(cwd);
99
93
  let mtimeMs;
100
94
  try {
101
95
  mtimeMs = (await stat(bindingFile)).mtimeMs;
@@ -103,24 +97,23 @@ async function refreshBindingFromDisk() {
103
97
  catch (err) {
104
98
  const code = err.code;
105
99
  if (code === "ENOENT" || code === "ENOTDIR") {
106
- if (currentBinding !== null) {
107
- currentBinding = null;
108
- bindingMtimeMs = null;
109
- }
110
- return;
100
+ bindingCache.set(cwd, { binding: null, mtimeMs: null });
101
+ return null;
111
102
  }
112
103
  // Non-fatal: keep the last known binding.
113
- return;
104
+ return cached?.binding ?? null;
114
105
  }
115
- if (mtimeMs === bindingMtimeMs)
116
- return;
106
+ if (cached && mtimeMs === cached.mtimeMs)
107
+ return cached.binding;
117
108
  try {
118
- currentBinding = await readStudioBinding(sessionCwd);
119
- bindingMtimeMs = mtimeMs;
109
+ const binding = await readStudioBinding(cwd);
110
+ bindingCache.set(cwd, { binding, mtimeMs });
111
+ return binding;
120
112
  }
121
113
  catch {
122
114
  // Parse error etc. — keep last known binding, force re-read next time.
123
- bindingMtimeMs = null;
115
+ bindingCache.set(cwd, { binding: cached?.binding ?? null, mtimeMs: null });
116
+ return cached?.binding ?? null;
124
117
  }
125
118
  }
126
119
  /**
@@ -141,15 +134,15 @@ const MODEL_INJECTING_TOOLS = new Set([
141
134
  ]);
142
135
  const SPECTRAL_PROXY_USER_MODEL = "spectral-proxy-user-model";
143
136
  const BUILT_IN_PREFIX = "built-in/";
144
- function augmentParams(toolName, params, ctx) {
137
+ function augmentParams(toolName, params, ctx, binding) {
145
138
  if (!toolName.startsWith("remote_"))
146
139
  return { params };
147
140
  const augmented = { ...params };
148
- if (currentBinding?.teamId && !("teamId" in params)) {
149
- augmented.teamId = currentBinding.teamId;
141
+ if (binding?.teamId && !("teamId" in params)) {
142
+ augmented.teamId = binding.teamId;
150
143
  }
151
- if (currentBinding?.projectId && !("projectId" in params)) {
152
- augmented.projectId = currentBinding.projectId;
144
+ if (binding?.projectId && !("projectId" in params)) {
145
+ augmented.projectId = binding.projectId;
153
146
  }
154
147
  if (MODEL_INJECTING_TOOLS.has(toolName)) {
155
148
  const model = ctx.model;
@@ -185,13 +178,12 @@ export default async function aexolMcpExtension(ext) {
185
178
  const client = new AexolMcpClient(apiUrl, token);
186
179
  // ---- Load local Studio binding on session start --------------------------
187
180
  ext.on("session_start", async (_event, ctx) => {
188
- sessionCwd = ctx.cwd;
189
181
  try {
190
- await refreshBindingFromDisk();
191
- if (currentBinding) {
192
- const displayName = currentBinding.name ?? currentBinding.projectId;
193
- process.stderr.write(`[aexol-mcp] Studio binding found: ${displayName} (${currentBinding.projectId})` +
194
- (currentBinding.teamId ? `, team ${currentBinding.teamId}` : "") +
182
+ const binding = await refreshBindingForCwd(ctx.cwd);
183
+ if (binding) {
184
+ const displayName = binding.name ?? binding.projectId;
185
+ process.stderr.write(`[aexol-mcp] Studio binding found: ${displayName} (${binding.projectId})` +
186
+ (binding.teamId ? `, team ${binding.teamId}` : "") +
195
187
  `\n`);
196
188
  }
197
189
  }
@@ -253,8 +245,8 @@ export default async function aexolMcpExtension(ext) {
253
245
  description: tool.description ?? "",
254
246
  parameters,
255
247
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
256
- await refreshBindingFromDisk();
257
- const augmented = augmentParams(tool.name, params, ctx);
248
+ const binding = await refreshBindingForCwd(ctx.cwd);
249
+ const augmented = augmentParams(tool.name, params, ctx, binding);
258
250
  if ("error" in augmented) {
259
251
  return {
260
252
  content: [{ type: "text", text: augmented.error }],
@@ -335,10 +327,8 @@ export default async function aexolMcpExtension(ext) {
335
327
  };
336
328
  try {
337
329
  await writeStudioBinding(binding, ctx.cwd);
338
- if (!sessionCwd)
339
- sessionCwd = ctx.cwd;
340
- currentBinding = binding;
341
- bindingMtimeMs = null;
330
+ // Invalidate the per-cwd cache so the next refreshBindingForCwd re-reads.
331
+ bindingCache.set(ctx.cwd, { binding, mtimeMs: null });
342
332
  const displayName = binding.name ?? binding.projectId;
343
333
  return {
344
334
  content: [{
@@ -377,20 +367,19 @@ export default async function aexolMcpExtension(ext) {
377
367
  properties: {},
378
368
  },
379
369
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
380
- if (!currentBinding) {
370
+ const binding = await refreshBindingForCwd(ctx.cwd);
371
+ if (!binding) {
381
372
  return {
382
373
  content: [{ type: "text", text: "No Studio binding found in this directory — nothing to unbind." }],
383
374
  details: { isError: false },
384
375
  };
385
376
  }
386
- const displayName = currentBinding.name ?? currentBinding.projectId;
387
- const projectId = currentBinding.projectId;
377
+ const displayName = binding.name ?? binding.projectId;
378
+ const projectId = binding.projectId;
388
379
  try {
389
380
  const deleted = await deleteStudioBinding(ctx.cwd);
390
- if (!sessionCwd)
391
- sessionCwd = ctx.cwd;
392
- currentBinding = null;
393
- bindingMtimeMs = null;
381
+ // Invalidate the per-cwd cache.
382
+ bindingCache.set(ctx.cwd, { binding: null, mtimeMs: null });
394
383
  if (deleted) {
395
384
  return {
396
385
  content: [{
@@ -430,8 +419,9 @@ export default async function aexolMcpExtension(ext) {
430
419
  type: "object",
431
420
  properties: {},
432
421
  },
433
- async execute() {
434
- if (!currentBinding) {
422
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
423
+ const binding = await refreshBindingForCwd(ctx.cwd);
424
+ if (!binding) {
435
425
  return {
436
426
  content: [{
437
427
  type: "text",
@@ -444,7 +434,7 @@ export default async function aexolMcpExtension(ext) {
444
434
  return {
445
435
  content: [{
446
436
  type: "text",
447
- text: JSON.stringify(currentBinding, null, 2),
437
+ text: JSON.stringify(binding, null, 2),
448
438
  }],
449
439
  details: { isError: false },
450
440
  };
@@ -1 +1 @@
1
- {"version":3,"file":"kanban-bridge.d.ts","sourceRoot":"","sources":["../../src/extensions/kanban-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoC,MAAM,8BAA8B,CAAC;AA41BnG,wBAA8B,qBAAqB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAqCpF"}
1
+ {"version":3,"file":"kanban-bridge.d.ts","sourceRoot":"","sources":["../../src/extensions/kanban-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAoC,MAAM,8BAA8B,CAAC;AA09BnG,wBAA8B,qBAAqB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAoCpF"}