@saadnvd1/agent-os 0.3.575 → 0.3.576

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/lib/away/run.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  import { checkDeploy } from "./deploy";
56
56
  import { buildAwayReport, phoneSummary, reportMarkdown } from "./report";
57
57
  import { awayOnLine } from "./brief";
58
+ import { shutFinishedTaskSimulators } from "../simulator";
58
59
 
59
60
  const PHONE_SOURCE = "away";
60
61
  const PHONE_LINES = 8;
@@ -582,6 +583,9 @@ export function awayTick(now = Date.now()): void {
582
583
  console.error(`[away] sweep of ${workspaceId}:`, error);
583
584
  }
584
585
  }
586
+ await shutFinishedTaskSimulators().catch((error) =>
587
+ console.error("[away] simulator sweep:", error)
588
+ );
585
589
  // Ended meanwhile: the report's summary says what's waiting.
586
590
  const still = activeAway();
587
591
  if (still?.id === away.id) notifyBatch(still, Date.now());
@@ -2,10 +2,11 @@ import type { Session } from "../db";
2
2
  import { stopChat } from "../chat/runner";
3
3
  import { waitForExit } from "../chat/worker/client";
4
4
  import { endTmuxSession } from "../tmux/end";
5
- import { releaseOnEnd } from "../simulator";
5
+ import { releaseOnEnd, shutTaskSimulators } from "../simulator";
6
6
  import { forgetPlanWait } from "../models/plan-step";
7
7
 
8
- type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
8
+ type Agent = Pick<Session, "id" | "host_id" | "tmux_name"> &
9
+ Partial<Pick<Session, "worktree_path" | "branch_name">>;
9
10
 
10
11
  // A session's agent, ended: its chat worker, and its own tmux session (a
11
12
  // chat session can still own one). The one call that delete, done, drop and
@@ -15,6 +16,8 @@ export async function endSessionAgent(s: Agent): Promise<void> {
15
16
  stopChat(s.id);
16
17
  // The task's simulator goes with it; no row, no work.
17
18
  releaseOnEnd(s.id, { remove: true });
19
+ // And any the project's own scripts booted for it; shut down only.
20
+ shutTaskSimulators(s);
18
21
  void waitForExit(s.id).catch(() => {});
19
22
  await endTmuxSession(s.host_id, s.tmux_name);
20
23
  }
@@ -60,6 +60,7 @@ export class SimulatorError extends Error {
60
60
  }
61
61
 
62
62
  export { simulatorAvailable };
63
+ export { shutTaskSimulators, shutFinishedTaskSimulators } from "./task-devices";
63
64
 
64
65
  const session = (id: string) =>
65
66
  db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as
@@ -142,6 +142,16 @@ export async function shutdownDevice(device: {
142
142
  }
143
143
  }
144
144
 
145
+ // Shut down only, never delete or erase. Also used for devices a project's
146
+ // own scripts made, matched to a finished task by name (task-devices.ts).
147
+ export async function shutdownByUdid(udid: string): Promise<void> {
148
+ try {
149
+ await run(["shutdown", udid]);
150
+ } catch (error) {
151
+ if (!/current state: Shutdown/i.test(String(error))) throw error;
152
+ }
153
+ }
154
+
145
155
  export async function deleteDevice(device: {
146
156
  udid: string;
147
157
  name: string;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Booted simulators that belong to a task, shut down when it ends. Two naming
3
+ * schemes: AgentOS's own (`aos-<instance>-<task id>`, from `aos sim`) and the
4
+ * agentos-mobile project's (`agentos-<branch>-<worktree hash>`, from its
5
+ * `npm run sim`). Shut down only: nothing here deletes or erases a device.
6
+ */
7
+
8
+ import { createHash } from "crypto";
9
+ import fs from "fs";
10
+ import { db, type Session } from "../db";
11
+ import { isRemoteHost } from "../hosts";
12
+ import { simulatorAvailable } from "./available";
13
+ import {
14
+ deviceNameFor,
15
+ listDevices,
16
+ shutdownByUdid,
17
+ type SimDevice,
18
+ } from "./simctl";
19
+
20
+ // Same as agentos-mobile's scripts/lib/sim-helpers.mjs (slug, simName); the
21
+ // two must stay in step.
22
+ export const simSlug = (text: string) =>
23
+ text
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, "-")
26
+ .replace(/^-+|-+$/g, "")
27
+ .slice(0, 40);
28
+
29
+ export const projectSimName = (
30
+ branch: string | null | undefined,
31
+ worktreePath: string
32
+ ) =>
33
+ `agentos-${simSlug(branch || "detached") || "detached"}-${createHash("sha1")
34
+ .update(worktreePath)
35
+ .digest("hex")
36
+ .slice(0, 6)}`;
37
+
38
+ export const FINISHED = new Set(["merged", "dropped", "done"]);
39
+
40
+ export interface TaskRef {
41
+ id: string;
42
+ branch_name: string | null;
43
+ worktree_path: string | null;
44
+ task_status?: string | null;
45
+ }
46
+
47
+ // The script resolves its worktree through symlinks; the row may not.
48
+ export function pathVariants(p: string | null): string[] {
49
+ if (!p) return [];
50
+ try {
51
+ return [...new Set([p, fs.realpathSync(p)])];
52
+ } catch {
53
+ return [p];
54
+ }
55
+ }
56
+
57
+ export function taskDeviceNames(
58
+ t: Pick<TaskRef, "id" | "branch_name">,
59
+ paths: string[]
60
+ ): string[] {
61
+ return [
62
+ ...new Set([
63
+ deviceNameFor(t.id),
64
+ ...paths.map((p) => projectSimName(t.branch_name, p)),
65
+ ]),
66
+ ];
67
+ }
68
+
69
+ const running = (d: SimDevice) => d.state === "Booted" || d.state === "Booting";
70
+
71
+ // Running devices whose every matching task is finished: a task re-run on the
72
+ // same branch and worktree keeps its device, and unmatched ones are not ours.
73
+ export function planFinishedShutdowns(
74
+ devices: SimDevice[],
75
+ tasks: (TaskRef & { paths?: string[] })[]
76
+ ): SimDevice[] {
77
+ const owners = new Map<string, boolean[]>();
78
+ for (const t of tasks) {
79
+ const finished = FINISHED.has(t.task_status ?? "");
80
+ const paths = t.paths ?? pathVariants(t.worktree_path);
81
+ for (const name of taskDeviceNames(t, paths))
82
+ owners.set(name, [...(owners.get(name) ?? []), finished]);
83
+ }
84
+ return devices.filter((d) => {
85
+ const o = owners.get(d.name);
86
+ return running(d) && !!o && o.every(Boolean);
87
+ });
88
+ }
89
+
90
+ const enabled = () =>
91
+ simulatorAvailable() && process.env.AGENTOS_SIM_SHUTDOWN !== "off";
92
+
93
+ async function shutAll(devices: SimDevice[], why: string): Promise<number> {
94
+ let n = 0;
95
+ for (const d of devices) {
96
+ try {
97
+ await shutdownByUdid(d.udid);
98
+ n++;
99
+ console.log(`[simulator] shut down ${d.name}: ${why}`);
100
+ } catch (error) {
101
+ console.error(`[simulator] shutdown ${d.name}:`, error);
102
+ }
103
+ }
104
+ return n;
105
+ }
106
+
107
+ // For a task that is ending. Never blocks and never throws into the cleanup.
108
+ export function shutTaskSimulators(
109
+ s: Pick<Session, "id" | "host_id"> &
110
+ Partial<Pick<Session, "branch_name" | "worktree_path">>
111
+ ): void {
112
+ try {
113
+ if (!enabled() || isRemoteHost(s.host_id)) return;
114
+ // Before the caller removes the worktree, which realpath needs.
115
+ const names = new Set(
116
+ taskDeviceNames(
117
+ { id: s.id, branch_name: s.branch_name ?? null },
118
+ pathVariants(s.worktree_path ?? null)
119
+ )
120
+ );
121
+ void listDevices()
122
+ .then((devices) =>
123
+ shutAll(
124
+ devices.filter((d) => running(d) && names.has(d.name)),
125
+ "its task ended"
126
+ )
127
+ )
128
+ .catch((error) => console.error("[simulator] list:", error));
129
+ } catch (error) {
130
+ console.error("[simulator] shutTaskSimulators:", error);
131
+ }
132
+ }
133
+
134
+ let sweeping = false;
135
+
136
+ // Shuts down booted simulators whose task is merged, dropped or done.
137
+ export async function shutFinishedTaskSimulators(): Promise<number> {
138
+ if (!enabled() || sweeping) return 0;
139
+ sweeping = true;
140
+ try {
141
+ const devices = (await listDevices()).filter(
142
+ (d) =>
143
+ (running(d) && d.name.startsWith("agentos-")) ||
144
+ (running(d) && d.name.startsWith("aos-"))
145
+ );
146
+ if (devices.length === 0) return 0;
147
+ const tasks = (
148
+ db
149
+ .prepare(
150
+ `SELECT id, branch_name, worktree_path, task_status, host_id
151
+ FROM sessions WHERE task_status IS NOT NULL`
152
+ )
153
+ .all() as (TaskRef & { host_id: string })[]
154
+ ).filter((t) => !isRemoteHost(t.host_id));
155
+ const stale = planFinishedShutdowns(devices, tasks);
156
+ return await shutAll(stale, "its task is finished");
157
+ } finally {
158
+ sweeping = false;
159
+ }
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.575",
3
+ "version": "0.3.576",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/server.ts CHANGED
@@ -79,7 +79,7 @@ import { claimAgentosEnv } from "./lib/agents/self-env";
79
79
  import { wslNotes, wslVersion } from "./lib/wsl";
80
80
  import { getAllProjects } from "./lib/projects";
81
81
  import { isRemoteHost } from "./lib/hosts";
82
- import { sweepSimulators } from "./lib/simulator";
82
+ import { shutFinishedTaskSimulators, sweepSimulators } from "./lib/simulator";
83
83
 
84
84
  const dev = process.env.NODE_ENV !== "production";
85
85
  const hostname = "127.0.0.1";
@@ -385,6 +385,7 @@ app.prepare().then(async () => {
385
385
  sweepingSims = true;
386
386
  try {
387
387
  await sweepSimulators();
388
+ await shutFinishedTaskSimulators();
388
389
  } catch (error) {
389
390
  console.error("Sweeping simulators failed:", error);
390
391
  } finally {