@saadnvd1/agent-os 0.3.692 → 0.3.693

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.
@@ -61,6 +61,7 @@ export async function POST(request: NextRequest) {
61
61
  const { session, initialPrompt, repeat } = await launchSession({
62
62
  id: str(body.id),
63
63
  projectId: str(body.projectId),
64
+ workspaceId: str(body.workspaceId),
64
65
  project: projectRefOf(body.project),
65
66
  workingDirectory: str(body.workingDirectory),
66
67
  agentType:
@@ -98,11 +98,16 @@ export function DraftChips({
98
98
  }, [hostGone, onChange]);
99
99
 
100
100
  const pickProject = (id: string | null) => {
101
- const next = newDraft(draft.id, real.find((p) => p.id === id) ?? null, {
102
- agentType: draft.agentType,
103
- model: draft.model,
104
- access: draft.access,
105
- });
101
+ const next = newDraft(
102
+ draft.id,
103
+ real.find((p) => p.id === id) ?? null,
104
+ {
105
+ agentType: draft.agentType,
106
+ model: draft.model,
107
+ access: draft.access,
108
+ },
109
+ { workspaceId: draft.workspaceId }
110
+ );
106
111
  onChange({
107
112
  ...next,
108
113
  openPr: !!id && draft.openPr,
@@ -12,6 +12,7 @@ import { discoversModels } from "@/lib/model-catalog";
12
12
  import { useDiscoveredModels } from "@/data/agents";
13
13
  import { useHostsQuery } from "@/data/hosts";
14
14
  import { useGitCheck } from "@/data/git/queries";
15
+ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
15
16
  import { useLaunchSession } from "@/data/sessions";
16
17
  import { useCreateTask } from "@/data/tasks";
17
18
  import { usePanes } from "@/contexts/PaneContext";
@@ -50,6 +51,7 @@ export function DraftPanel({
50
51
  discovers
51
52
  );
52
53
  const launch = useLaunchSession();
54
+ const { workspace } = useSelectedWorkspace();
53
55
  const createTask = useCreateTask();
54
56
  const [prefill, setPrefill] = useState<{ text: string; at: number }>();
55
57
  const pending = launch.isPending || createTask.isPending;
@@ -93,6 +95,7 @@ export function DraftPanel({
93
95
  }
94
96
  const { session, initialPrompt, repeat } = await launch.mutateAsync({
95
97
  projectId: project?.id ?? null,
98
+ workspaceId: draft.workspaceId ?? workspace?.id ?? null,
96
99
  id: draft.id,
97
100
  hostId:
98
101
  !project || draft.hostId !== project.host_id
@@ -182,6 +182,8 @@ export interface LaunchSessionInput {
182
182
  // The draft's id: the session's, and a resend's key to the same one.
183
183
  id: string;
184
184
  projectId: string | null;
185
+ // The workspace selected: where a session in no project is filed.
186
+ workspaceId?: string | null;
185
187
  // A scratch chat's machine; a project's sessions run where it lives, or
186
188
  // on a linked machine.
187
189
  hostId?: string;
@@ -46,9 +46,15 @@ export function useDraftRequests(options: Options) {
46
46
  useEffect(() => {
47
47
  hydrateDrafts();
48
48
  const open = (projectId: string | null, openPr = false) => {
49
- const { projects, viewing, show } = latest.current;
49
+ const { projects, viewing, show, workspaceId } = latest.current;
50
50
  const drafts = Object.values(draftsStore.drafts) as Draft[];
51
- const found = reusableDraft(drafts, projectId, draftHasText, openPr);
51
+ const found = reusableDraft(
52
+ drafts,
53
+ projectId,
54
+ draftHasText,
55
+ openPr,
56
+ workspaceId
57
+ );
52
58
  if (found) return show(found.id);
53
59
  const from = viewing.draftId ? draftsStore.drafts[viewing.draftId] : null;
54
60
  const s = viewing.session;
@@ -58,7 +64,7 @@ export function useDraftRequests(options: Options) {
58
64
  ? { agentType: s.agent_type, model: s.model, access: s.chat_access }
59
65
  : {};
60
66
  const project = projects.find((p) => p.id === projectId) ?? null;
61
- const draft = makeDraft(uuid(), project, carry, { openPr });
67
+ const draft = makeDraft(uuid(), project, carry, { openPr, workspaceId });
62
68
  draftsActions.put(draft);
63
69
  show(draft.id);
64
70
  };
@@ -60,7 +60,8 @@ function inScope(s: ReadableSession, scope: ReaderScope): boolean {
60
60
  if (s.role) return false;
61
61
  if (scope.workspaceId) {
62
62
  const p = s.project_id ? getProject(s.project_id) : null;
63
- return !!p && p.workspace_id === scope.workspaceId;
63
+ // A session in no project is in the workspace it was started in.
64
+ return (p?.workspace_id ?? s.workspace_id) === scope.workspaceId;
64
65
  }
65
66
  return s.project_id === scope.projectId;
66
67
  }
package/lib/drafts.ts CHANGED
@@ -13,6 +13,8 @@ export interface Draft {
13
13
  id: string;
14
14
  // null: a scratch chat, in no project.
15
15
  projectId: string | null;
16
+ // The workspace selected when it was made: where a scratch chat is filed.
17
+ workspaceId?: string | null;
16
18
  hostId: string;
17
19
  agentType: AgentType;
18
20
  model: string;
@@ -55,13 +57,14 @@ export function newDraft(
55
57
  id: string,
56
58
  project: DraftProject | null,
57
59
  carry: Carry,
58
- opts: { openPr?: boolean; now?: number } = {}
60
+ opts: { openPr?: boolean; now?: number; workspaceId?: string | null } = {}
59
61
  ): Draft {
60
62
  const real = project && !project.is_uncategorized ? project : null;
61
63
  const agentType = real?.agent_type ?? carry.agentType ?? "claude";
62
64
  return {
63
65
  id,
64
66
  projectId: real?.id ?? null,
67
+ workspaceId: real ? null : (opts.workspaceId ?? null),
65
68
  hostId: real?.host_id || "local",
66
69
  agentType,
67
70
  model: resolveModelForAgent(
@@ -82,11 +85,18 @@ export function reusableDraft(
82
85
  drafts: Draft[],
83
86
  projectId: string | null,
84
87
  hasText: (draftId: string) => boolean,
85
- openPr = false
88
+ openPr = false,
89
+ workspaceId: string | null = null
86
90
  ): Draft | null {
91
+ // An empty scratch chat from another workspace would file this one's
92
+ // session there.
87
93
  return (
88
94
  drafts.find(
89
- (d) => d.projectId === projectId && d.openPr === openPr && !hasText(d.id)
95
+ (d) =>
96
+ d.projectId === projectId &&
97
+ d.openPr === openPr &&
98
+ (projectId !== null || (d.workspaceId ?? null) === workspaceId) &&
99
+ !hasText(d.id)
90
100
  ) ?? null
91
101
  );
92
102
  }
package/lib/projects.ts CHANGED
@@ -278,11 +278,12 @@ export function deleteProject(id: string): boolean {
278
278
  const project = getProject(id);
279
279
  if (!project || project.is_uncategorized) return false;
280
280
 
281
- // Move all sessions to Uncategorized
282
- const sessions = queries.getSessionsByProject(db).all(id) as Session[];
283
- for (const session of sessions) {
284
- queries.updateSessionProject(db).run("uncategorized", session.id);
285
- }
281
+ // Its sessions go to Uncategorized, staying in the project's workspace.
282
+ db.prepare(
283
+ `UPDATE sessions SET project_id = 'uncategorized', updated_at = datetime('now'),
284
+ workspace_id = CASE WHEN role IS NULL THEN ? ELSE workspace_id END
285
+ WHERE project_id = ?`
286
+ ).run(project.workspace_id ?? null, id);
286
287
 
287
288
  // Delete dev server instances
288
289
  queries.deleteDevServersByProject(db).run(id);
@@ -312,7 +313,27 @@ export function moveSessionToProject(
312
313
  sessionId: string,
313
314
  projectId: string
314
315
  ): void {
315
- queries.updateSessionProject(db).run(projectId, sessionId);
316
+ const to = getProject(projectId);
317
+ const from = db
318
+ .prepare(
319
+ `SELECT p.workspace_id AS workspace_id, s.workspace_id AS own
320
+ FROM sessions s LEFT JOIN projects p ON p.id = s.project_id WHERE s.id = ?`
321
+ )
322
+ .get(sessionId) as
323
+ { workspace_id: string | null; own: string | null } | undefined;
324
+ db.transaction(() => {
325
+ queries.updateSessionProject(db).run(projectId, sessionId);
326
+ // A project's session follows the project; one left in none keeps the
327
+ // workspace it was in.
328
+ db.prepare(
329
+ `UPDATE sessions SET workspace_id = ? WHERE id = ? AND role IS NULL`
330
+ ).run(
331
+ to && !to.is_uncategorized
332
+ ? null
333
+ : (from?.workspace_id ?? from?.own ?? null),
334
+ sessionId
335
+ );
336
+ })();
316
337
  }
317
338
 
318
339
  /**
@@ -16,6 +16,7 @@ import { isDiscoveredModel } from "../chat/discovered-models";
16
16
  import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
17
17
  import { ANTHROPIC } from "../models/roles";
18
18
  import { getProject } from "../projects";
19
+ import { getWorkspace } from "../workspaces";
19
20
  import { getHost, isRemoteHost } from "../hosts";
20
21
  import { hostLink } from "../hosts/remote-api";
21
22
  import { startOnPeer } from "../hosts/peer-actions";
@@ -78,6 +79,9 @@ export interface LaunchInput {
78
79
  // role's model (or `model`), not the interactive one's.
79
80
  startedBy?: string | null;
80
81
  startedVia?: StartVia;
82
+ // The workspace selected when it was started. A session in a project
83
+ // follows the project; one in none keeps this (see inWorkspace).
84
+ workspaceId?: string | null;
81
85
  groupPath?: string;
82
86
  systemPrompt?: string | null;
83
87
  }
@@ -178,6 +182,29 @@ export function launchSession(input: LaunchInput): Promise<Launched> {
178
182
  return p;
179
183
  }
180
184
 
185
+ // The workspace a project-less session is filed under: the one it was started
186
+ // from, else that of the orchestrator starting it. A workspace deleted since
187
+ // the draft was made is dropped rather than failing the send.
188
+ function homeWorkspace(input: LaunchInput): string | null {
189
+ const picked = input.workspaceId?.trim();
190
+ if (picked) return getWorkspace(picked) ? picked : null;
191
+ if (!input.startedBy) return null;
192
+ const by = db
193
+ .prepare(
194
+ `SELECT workspace_id FROM sessions WHERE id = ? AND role = 'orchestrator'`
195
+ )
196
+ .get(input.startedBy) as { workspace_id: string | null } | undefined;
197
+ return by?.workspace_id ?? null;
198
+ }
199
+
200
+ function setHome(id: string, workspaceId: string | null): void {
201
+ if (workspaceId)
202
+ db.prepare(`UPDATE sessions SET workspace_id = ? WHERE id = ?`).run(
203
+ workspaceId,
204
+ id
205
+ );
206
+ }
207
+
181
208
  async function launch(input: LaunchInput, id: string): Promise<Launched> {
182
209
  const project = input.project
183
210
  ? await ensureProject(input.project)
@@ -185,6 +212,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
185
212
  ? getProject(input.projectId)
186
213
  : undefined;
187
214
  const scratch = !project || project.is_uncategorized;
215
+ const home = scratch ? homeWorkspace(input) : null;
188
216
  const hostId = (scratch ? input.hostId : null) || project?.host_id || "local";
189
217
  // A project here can start on a linked machine, which finds or clones it.
190
218
  const carried =
@@ -210,7 +238,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
210
238
  if (link) {
211
239
  if (input.images?.length || input.files?.length)
212
240
  throw new Error(`Images and files can't be sent to ${link.hostName} yet`);
213
- return startOnPeer(link, {
241
+ const started = await startOnPeer(link, {
214
242
  id,
215
243
  project: scratch ? null : (project ?? null),
216
244
  ref: carried && project ? await projectRef(project) : undefined,
@@ -222,6 +250,10 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
222
250
  prompt: input.prompt?.trim(),
223
251
  view: input.view ?? DEFAULT_START_VIEW,
224
252
  });
253
+ setHome(started.session.id, home);
254
+ return home
255
+ ? { ...started, session: { ...started.session, workspace_id: home } }
256
+ : started;
225
257
  }
226
258
  const projectPath = expand(folder);
227
259
  if (scratch && projectPath === SCRATCH_DIR)
@@ -242,7 +274,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
242
274
  const routed =
243
275
  agentType === "claude" && (picked || !input.model?.trim()) && !remote
244
276
  ? resolveRole(role, {
245
- workspaceId: project?.workspace_id,
277
+ workspaceId: project?.workspace_id ?? home,
246
278
  override: picked,
247
279
  })
248
280
  : null;
@@ -302,6 +334,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
302
334
  input.startedVia ?? (input.startedBy ? "orchestrator" : null),
303
335
  id
304
336
  );
337
+ setHome(id, home);
305
338
  if (interactive) setSessionModel(id, interactive);
306
339
  if (input.access)
307
340
  db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
@@ -382,7 +382,9 @@ export function doneSummary(
382
382
  }
383
383
 
384
384
  // A workspace holds its projects' sessions and its own orchestrator; no
385
- // workspace selected means every session.
385
+ // workspace selected means every session. A session in a project follows the
386
+ // project, so moving it moves them; one in no project (scratch) belongs to the
387
+ // workspace it was started in, kept on the session.
386
388
  export function inWorkspace(
387
389
  session: Pick<Session, "project_id" | "workspace_id" | "role">,
388
390
  workspaceOf: (projectId: string) => string | null | undefined,
@@ -391,9 +393,8 @@ export function inWorkspace(
391
393
  if (!workspaceId) return true;
392
394
  if (session.role === "orchestrator")
393
395
  return session.workspace_id === workspaceId;
394
- return (
395
- !!session.project_id && workspaceOf(session.project_id) === workspaceId
396
- );
396
+ const byProject = session.project_id ? workspaceOf(session.project_id) : null;
397
+ return (byProject ?? session.workspace_id) === workspaceId;
397
398
  }
398
399
 
399
400
  // A workspace's projects, or every project when none is selected.
package/lib/workspaces.ts CHANGED
@@ -115,6 +115,11 @@ export function deleteWorkspace(id: string): void {
115
115
  `UPDATE projects SET workspace_id = NULL WHERE workspace_id = ?`
116
116
  ).run(id);
117
117
  db.prepare(`DELETE FROM workspaces WHERE id = ?`).run(id);
118
+ // Sessions filed here by being started here go back to "All workspaces".
119
+ db.prepare(
120
+ `UPDATE sessions SET workspace_id = NULL
121
+ WHERE role IS NULL AND workspace_id = ?`
122
+ ).run(id);
118
123
  // Its orchestrator goes with it; a running worker idles out on its own.
119
124
  db.prepare(
120
125
  `DELETE FROM sessions WHERE role = 'orchestrator' AND workspace_id = ?`
@@ -128,6 +133,7 @@ export function deleteWorkspace(id: string): void {
128
133
  })();
129
134
  }
130
135
 
136
+ // A project's sessions follow it, so moving one needs no change to them.
131
137
  export function setProjectWorkspace(
132
138
  projectId: string,
133
139
  workspaceId: string | null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.692",
3
+ "version": "0.3.693",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"