@aexol/spectral 0.6.8 → 0.7.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.
package/dist/cli.js CHANGED
@@ -136,15 +136,19 @@ function printHeader() {
136
136
  // ---- Delegate to pi ----------------------------------------------------------
137
137
  function delegateToPi(args) {
138
138
  const piBin = resolvePiBin();
139
+ // The subprocess inherits a snapshot of process.env (which already has
140
+ // all interactive-editor vars disabled by disableInteractiveGitEditors()),
141
+ // but we pass it explicitly anyway as defense-in-depth in case the user
142
+ // overrides any of these in their shell rc files before launching spectral.
139
143
  const child = spawn(process.execPath, [piBin, ...args], {
140
144
  stdio: "inherit",
141
145
  env: {
142
146
  ...process.env,
143
- // Prevent git from opening an interactive editor when the agent
144
- // runs commands like `git rebase --continue`, `git commit` (without -m),
145
- // or `git merge`. Without this, the editor hangs forever waiting for
146
- // TTY input that doesn't exist in the agent's non-interactive shell.
147
147
  GIT_EDITOR: "true",
148
+ GIT_SEQUENCE_EDITOR: "true",
149
+ EDITOR: "true",
150
+ VISUAL: "true",
151
+ GIT_PAGER: "cat",
148
152
  },
149
153
  });
150
154
  // Forward common termination signals to pi so its TUI can clean up.
@@ -177,8 +181,32 @@ function delegateToPi(args) {
177
181
  // TypeScript needs a `never` terminator.
178
182
  return undefined;
179
183
  }
184
+ // ---- Disable interactive git editors ----------------------------------------
185
+ // Set at process level so BOTH direct mode (delegateToPi → subprocess inherits)
186
+ // AND serve mode (PiBridge in-process → getShellEnv() returns process.env) are
187
+ // covered. Without this, `git rebase --continue`, `git commit` (without -m),
188
+ // `git merge`, etc. open an editor that hangs forever — the agent's bash tool
189
+ // runs with `stdio: ["ignore", ...]`, so there is no TTY to interact with.
190
+ function disableInteractiveGitEditors() {
191
+ const editorVars = {
192
+ GIT_EDITOR: "true",
193
+ GIT_SEQUENCE_EDITOR: "true",
194
+ EDITOR: "true",
195
+ VISUAL: "true",
196
+ // cat pipes output straight through; prevents git's pager (less) from
197
+ // blocking on a non-existent TTY.
198
+ GIT_PAGER: "cat",
199
+ };
200
+ for (const [key, value] of Object.entries(editorVars)) {
201
+ if (!process.env[key]) {
202
+ process.env[key] = value;
203
+ }
204
+ }
205
+ }
180
206
  // ---- Main --------------------------------------------------------------------
181
207
  async function main() {
208
+ // Must be called before any pi process is spawned (direct or serve).
209
+ disableInteractiveGitEditors();
182
210
  const args = process.argv.slice(2);
183
211
  const first = args[0];
184
212
  // Branded short-circuits: never require login.
@@ -45,8 +45,23 @@ function renderContentToString(content) {
45
45
  }
46
46
  if (typeof first.text === "string")
47
47
  return first.text;
48
+ // For image/audio/binary content types, return a metadata placeholder
49
+ // instead of serializing the raw base64 data into the model context.
50
+ if (first.type === "image" || first.type === "audio") {
51
+ const mimeType = typeof first.mimeType === "string" ? first.mimeType : first.type;
52
+ const dataLen = typeof first.data === "string" ? first.data.length : 0;
53
+ return `[${first.type}: ${mimeType}${dataLen ? `, ${dataLen.toLocaleString()} bytes base64` : ""}]`;
54
+ }
48
55
  try {
49
- return JSON.stringify(content, null, 2);
56
+ // Strip large binary fields from serialization to prevent context overflow.
57
+ const safe = content.map((c) => {
58
+ const copy = { ...c };
59
+ if ("data" in copy && typeof copy.data === "string" && copy.data.length > 200) {
60
+ copy.data = `[${copy.data.length.toLocaleString()} bytes base64 omitted]`;
61
+ }
62
+ return copy;
63
+ });
64
+ return JSON.stringify(safe, null, 2);
50
65
  }
51
66
  catch {
52
67
  return String(content);
@@ -57,7 +57,14 @@ export function transformMcpContent(content) {
57
57
  }
58
58
  if (c.type === "resource") {
59
59
  const resourceUri = c.resource?.uri ?? "(no URI)";
60
- const resourceContent = c.resource?.text ?? (c.resource ? JSON.stringify(c.resource) : "(no content)");
60
+ // Blob resources contain base64 binary data never serialize the raw
61
+ // blob into the model context (causes context overflow and dead sessions).
62
+ const resourceContent = c.resource?.text ??
63
+ ("blob" in (c.resource ?? {}) && c.resource.blob
64
+ ? `[Binary blob: ${c.resource.mimeType ?? "application/octet-stream"}]`
65
+ : c.resource
66
+ ? JSON.stringify(c.resource)
67
+ : "(no content)");
61
68
  return truncateTextBlock({
62
69
  type: "text",
63
70
  text: `[Resource: ${resourceUri}]\n${resourceContent}`,
@@ -77,6 +84,15 @@ export function transformMcpContent(content) {
77
84
  text: `[Audio content: ${c.mimeType ?? "audio/*"}]`,
78
85
  };
79
86
  }
80
- return truncateTextBlock({ type: "text", text: JSON.stringify(c) });
87
+ // Unknown content type serialize as text but strip any potentially
88
+ // large binary fields (data, blob) to avoid context overflow.
89
+ const safe = { ...c };
90
+ if ("data" in safe && typeof safe.data === "string" && safe.data.length > 200) {
91
+ safe.data = `[${safe.data.length.toLocaleString()} bytes base64 omitted]`;
92
+ }
93
+ if ("blob" in safe && typeof safe.blob === "string" && safe.blob.length > 200) {
94
+ safe.blob = `[${safe.blob.length.toLocaleString()} bytes base64 omitted]`;
95
+ }
96
+ return truncateTextBlock({ type: "text", text: JSON.stringify(safe) });
81
97
  });
82
98
  }
@@ -40,7 +40,7 @@
40
40
  */
41
41
  import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
42
42
  import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
43
- import { handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
43
+ import { handleBindStudioProject, handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
44
44
  import { handleCompactSession, handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleGetSessionMemoryDetails, handleGetSessionMemoryStatus, handleUpdateSession, } from "../server/handlers/sessions.js";
45
45
  import { shutdownState } from "../server/shutdown.js";
46
46
  import { handleAutoResearch } from "./auto-research.js";
@@ -76,6 +76,14 @@ export function matchRoute(method, path) {
76
76
  return { route: "create_session" };
77
77
  return null;
78
78
  }
79
+ // /api/projects/:id/bind-studio
80
+ const bindStudioMatch = /^\/api\/projects\/([^/]+)\/bind-studio$/.exec(cleanPath);
81
+ if (bindStudioMatch) {
82
+ const id = decodeURIComponent(bindStudioMatch[1]);
83
+ if (method === "POST")
84
+ return { route: "bind_studio", id };
85
+ return null;
86
+ }
79
87
  // /api/projects/:id and /api/projects/:id/sessions
80
88
  const projectMatch = /^\/api\/projects\/([^/]+)(\/sessions)?$/.exec(cleanPath);
81
89
  if (projectMatch) {
@@ -251,6 +259,16 @@ async function dispatchRoute(match, body, deps) {
251
259
  });
252
260
  return result;
253
261
  }
262
+ case "bind_studio": {
263
+ const result = await handleBindStudioProject(store, id, asObject(body));
264
+ safePublish(publishMetaEvent, logger, {
265
+ type: "project_studio_bound",
266
+ projectId: id,
267
+ studioProjectId: result.studioProjectId,
268
+ studioProjectName: result.studioProjectName,
269
+ });
270
+ return result;
271
+ }
254
272
  case "list_project_sessions": {
255
273
  const activeIds = manager.getActiveTurnSessionIds();
256
274
  const sessions = handleListSessionsByProject(store, id);
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { validateProjectPath } from "../paths.js";
23
23
  import { BadRequestError, NotFoundError } from "./errors.js";
24
+ import { writeStudioBindingAt } from "../../studio-binding.js";
24
25
  export function handleListProjects(store) {
25
26
  return store.listProjects();
26
27
  }
@@ -78,6 +79,40 @@ export function handleDeleteProject(store, id) {
78
79
  throw new NotFoundError("Project not found");
79
80
  return { sessionIds: result.sessionIds };
80
81
  }
82
+ /**
83
+ * Bind a local project directory to an Aexol Studio project by writing
84
+ * `.aexol/aexol.jsonc` into the project's filesystem path.
85
+ */
86
+ export async function handleBindStudioProject(store, id, body) {
87
+ const project = store.getProject(id);
88
+ if (!project)
89
+ throw new NotFoundError("Project not found");
90
+ if (typeof body.studioProjectId !== "string" || !body.studioProjectId.trim()) {
91
+ throw new BadRequestError("studioProjectId (non-empty string) is required");
92
+ }
93
+ const studioProjectId = body.studioProjectId.trim();
94
+ const studioTeamId = typeof body.studioTeamId === "string" && body.studioTeamId.trim()
95
+ ? body.studioTeamId.trim()
96
+ : undefined;
97
+ const studioProjectName = typeof body.studioProjectName === "string" && body.studioProjectName.trim()
98
+ ? body.studioProjectName.trim()
99
+ : undefined;
100
+ const binding = {
101
+ $schema: "https://aexol.ai/schemas/studio-binding.json",
102
+ projectId: studioProjectId,
103
+ ...(studioTeamId ? { teamId: studioTeamId } : {}),
104
+ ...(studioProjectName ? { name: studioProjectName } : {}),
105
+ };
106
+ await writeStudioBindingAt(project.path, binding);
107
+ // Re-read to pick up the freshly written studio fields
108
+ const updated = store.getProject(id);
109
+ return {
110
+ project: updated,
111
+ studioProjectId,
112
+ studioProjectName: studioProjectName || studioProjectId,
113
+ ...(studioTeamId ? { studioTeamId } : {}),
114
+ };
115
+ }
81
116
  export function handleListSessionsByProject(store, id) {
82
117
  const project = store.getProject(id);
83
118
  if (!project)
@@ -699,6 +699,12 @@ export class SessionStreamManager {
699
699
  stream.loopOriginalPrompt = null;
700
700
  stream.loopGoal = null;
701
701
  stream.loopIterationCount = 0;
702
+ // Capture whether a turn is in-flight BEFORE we dispose the bridge.
703
+ // dispose() tears down pi, which can cause the in-flight prompt()
704
+ // promise to reject synchronously/microtask and emit an error event
705
+ // through handleBridgeEvent — which clears currentTurn. We must
706
+ // broadcast agent_end regardless of what dispose() does to currentTurn.
707
+ const hadTurn = stream.currentTurn != null;
702
708
  // Dispose the pi bridge immediately — this tears down pi's session and
703
709
  // unsubscribe. The bridge's own event handler is detached; no further
704
710
  // events will flow. We broadcast agent_end ourselves below.
@@ -718,8 +724,10 @@ export class SessionStreamManager {
718
724
  stream.currentMessageId = null;
719
725
  stream.lastFlushedEventCount = 0;
720
726
  // Broadcast agent_end so all subscribers close their open turn and
721
- // re-enable their composers.
722
- if (stream.currentTurn) {
727
+ // re-enable their composers. Use the pre-disposal flag — dispose()
728
+ // may have cleared currentTurn via an error event from the torn-down
729
+ // pi session.
730
+ if (hadTurn) {
723
731
  this.broadcast(stream, { type: "agent_end" });
724
732
  stream.currentTurn = null;
725
733
  }
@@ -79,6 +79,26 @@ export async function writeStudioBinding(binding) {
79
79
  "\n";
80
80
  await writeFile(bindingPath(), content, "utf8");
81
81
  }
82
+ /**
83
+ * Persist a Studio binding at an arbitrary project path (creates `.aexol/`
84
+ * under that directory). Uses the same format as `writeStudioBinding`.
85
+ */
86
+ export async function writeStudioBindingAt(projectPath, binding) {
87
+ const dir = join(projectPath, BINDING_DIR);
88
+ try {
89
+ await access(dir, constants.F_OK);
90
+ }
91
+ catch {
92
+ await mkdir(dir, { recursive: true });
93
+ }
94
+ const content = `// Aexol Studio Binding\n` +
95
+ `// Links this repository to a Studio project.\n` +
96
+ `// Schema: https://aexol.ai/schemas/studio-binding.json\n` +
97
+ `\n` +
98
+ JSON.stringify(binding, null, 2) +
99
+ "\n";
100
+ await writeFile(join(dir, BINDING_FILE), content, "utf8");
101
+ }
82
102
  /**
83
103
  * Remove the local Studio binding file.
84
104
  * Returns `true` when the file existed and was deleted, `false` otherwise (no-op).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.6.8",
3
+ "version": "0.7.0",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,