@autoscanlab/connect 0.4.1 → 0.4.2

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 (2) hide show
  1. package/connect.mjs +61 -12
  2. package/package.json +1 -1
package/connect.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
- import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { homedir, hostname, platform } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -10,6 +10,7 @@ import manifest from "./package.json" with { type: "json" };
10
10
 
11
11
  const IDLE_EXIT_MS = 30 * 60_000;
12
12
  const RETRY_MS = 5_000;
13
+ const OUTPUT_TAIL = 1_000;
13
14
  const AGENT_LABELS = { claude: "Claude Code", codex: "Codex" };
14
15
  const SCHEME = "autoscan";
15
16
  const KEYCHAIN_SERVICE = "autoscan-connect";
@@ -210,6 +211,7 @@ async function install(value, code) {
210
211
  const check = await reach(`${helperUrl(base, agents)}&wait=0`, { headers: { Authorization: `Bearer ${key}` } });
211
212
  if (!check.ok) fail((await check.text()) || "That key doesn't work for this course.");
212
213
  mkdirSync(APP_DIR, { recursive: true });
214
+ stopRunning(base);
213
215
  for (const file of ["connect.mjs", "package.json"]) copyFileSync(join(HERE, file), join(APP_DIR, file));
214
216
  saveKey(base, key);
215
217
  registerLink(join(APP_DIR, "connect.mjs"));
@@ -232,9 +234,34 @@ function uninstall() {
232
234
  console.log("Removed autOScan from this computer.");
233
235
  }
234
236
 
237
+ const lockOf = (base) => join(APP_DIR, `running-${createHash("sha256").update(base).digest("hex").slice(0, 12)}.pid`);
238
+
239
+ function stopRunning(base) {
240
+ const lock = lockOf(base);
241
+ if (!existsSync(lock)) return;
242
+ try {
243
+ process.kill(Number(readFileSync(lock, "utf8")), "SIGTERM");
244
+ } catch {}
245
+ rmSync(lock, { force: true });
246
+ }
247
+
248
+ function updateItself(base) {
249
+ console.log("Updating the helper…");
250
+ rmSync(lockOf(base), { force: true });
251
+ const npx = OS === "win32" ? "npx.cmd" : "npx";
252
+ const updated = spawnSync(npx, ["--prefer-online", "-y", `${manifest.name}@latest`, "install", base], { stdio: "inherit", shell: OS === "win32" });
253
+ if (updated.status !== 0) fail("Couldn't update the helper. Run the install command from autOScan's Agents page.");
254
+ // Restarting the same version would be told to update again, round and round.
255
+ if (JSON.parse(readFileSync(join(APP_DIR, "package.json"), "utf8")).version === manifest.version) fail("No newer helper is published yet; try again later.");
256
+ const log = openSync(join(APP_DIR, "helper.log"), "a");
257
+ const link = `${SCHEME}://connect?base=${encodeURIComponent(base)}`;
258
+ spawn(process.execPath, [join(APP_DIR, "connect.mjs"), "open", link], { detached: true, stdio: ["ignore", log, log] }).unref();
259
+ process.exit(0);
260
+ }
261
+
235
262
  // One helper per course: a second autoscan:// link while one runs leaves it be.
236
263
  function holdLock(base) {
237
- const lock = join(APP_DIR, `running-${createHash("sha256").update(base).digest("hex").slice(0, 12)}.pid`);
264
+ const lock = lockOf(base);
238
265
  if (existsSync(lock)) {
239
266
  try {
240
267
  process.kill(Number(readFileSync(lock, "utf8")), 0);
@@ -249,14 +276,15 @@ function holdLock(base) {
249
276
  async function run(base, key) {
250
277
  const agents = agentsOrFail();
251
278
  const headers = { Authorization: `Bearer ${key}` };
252
- const report = (running) =>
279
+ const report = (running, exit = null) =>
253
280
  fetch(`${base}/agent/helper`, {
254
281
  method: "POST",
255
282
  headers: { ...headers, "Content-Type": "application/json" },
256
- body: JSON.stringify({ running }),
283
+ body: JSON.stringify({ running, exit }),
257
284
  }).catch(() => {});
258
285
 
259
286
  let child = null;
287
+ let stopping = false;
260
288
  let idleSince = Date.now();
261
289
  let reachable = null;
262
290
  const announce = (now) => {
@@ -267,14 +295,29 @@ async function run(base, key) {
267
295
  const start = async ({ agent, workspace }) => {
268
296
  const { command, args, env } = agentCommand(agent, { mcpUrl: `${base}/mcp/grading`, key, workspace });
269
297
  console.log(`Grading ${workspace} with ${AGENT_LABELS[agent]}…`);
270
- child = spawn(command, args, { stdio: "inherit", env: { ...process.env, ...env } });
271
- await report({ workspace, agent });
272
- child.on("exit", () => {
298
+ const running = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
299
+ child = running;
300
+ let tail = "";
301
+ for (const [stream, log] of [[running.stdout, process.stdout], [running.stderr, process.stderr]]) {
302
+ stream.on("data", (chunk) => {
303
+ log.write(chunk);
304
+ tail = (tail + chunk).slice(-OUTPUT_TAIL);
305
+ });
306
+ }
307
+ let ended = false;
308
+ const end = (code, error) => {
309
+ if (ended) return;
310
+ ended = true;
273
311
  child = null;
274
312
  idleSince = Date.now();
275
- console.log("Agent stopped.");
276
- void report(null);
277
- });
313
+ const failed = !stopping && (error !== null || code !== 0);
314
+ stopping = false;
315
+ console.log(failed ? `Agent stopped unexpectedly (${error?.message ?? `exit code ${code}`}).` : "Agent stopped.");
316
+ void report(null, failed ? { agent, code, message: error?.message ?? (tail.trim() || `${AGENT_LABELS[agent]} exited with code ${code}.`) } : null);
317
+ };
318
+ running.on("error", (error) => end(null, error));
319
+ running.on("close", (code) => end(code, null));
320
+ await report({ workspace, agent });
278
321
  };
279
322
 
280
323
  while (child || Date.now() - idleSince < IDLE_EXIT_MS) {
@@ -288,8 +331,14 @@ async function run(base, key) {
288
331
  announce(true);
289
332
  if (response.status !== 200) continue;
290
333
  const command = await response.json();
291
- if (command.kind === "stop") child?.kill("SIGTERM");
292
- else if (!child && agents.includes(command.agent)) await start(command);
334
+ if (command.kind === "update" && !child) {
335
+ updateItself(base);
336
+ } else if (command.kind === "stop" && child) {
337
+ stopping = true;
338
+ child.kill("SIGTERM");
339
+ } else if (command.kind === "start" && !child && agents.includes(command.agent)) {
340
+ await start(command);
341
+ }
293
342
  }
294
343
  console.log("Idle for 30 minutes; disconnected.");
295
344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autoscanlab/connect",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Lets autOScan start your own Claude Code or Codex to grade alongside you.",
5
5
  "type": "module",
6
6
  "bin": {