@saadnvd1/agent-os 0.3.580 → 0.3.582

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.
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
 
3
- import type { MouseEvent } from "react";
4
3
  import {
5
4
  Bot,
6
5
  CalendarClock,
@@ -15,6 +14,7 @@ import { eventParts, originOf } from "@/lib/chat/origin";
15
14
  import { hrefWithSession } from "@/lib/session-url";
16
15
  import { cn } from "@/lib/utils";
17
16
  import { UserMessage } from "./Messages";
17
+ import { openSessionLink as openSession } from "./session-link";
18
18
  import { useRowState } from "./rowState";
19
19
 
20
20
  type UserItem = Extract<ChatItem, { kind: "user" }>;
@@ -34,14 +34,6 @@ const SHORT = 32;
34
34
  export const clock = (at: number) =>
35
35
  new Date(at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
36
36
 
37
- // Opens the session through its address, the way a shared link does.
38
- function openSession(e: MouseEvent<HTMLAnchorElement>, id: string) {
39
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
40
- e.preventDefault();
41
- window.history.pushState(null, "", hrefWithSession(location.href, id));
42
- window.dispatchEvent(new PopStateEvent("popstate"));
43
- }
44
-
45
37
  function Source({ origin }: { origin: ChatOrigin }) {
46
38
  const name = (
47
39
  <span className="text-foreground/80 block truncate font-medium">
@@ -12,27 +12,49 @@ import type { ChatItem } from "@/lib/chat/events";
12
12
  import { cn } from "@/lib/utils";
13
13
  import { leadingCommand } from "@/lib/chat/commands";
14
14
  import { compactedLine } from "@/lib/chat/stale";
15
- import { splitInlineCode, splitUserText } from "@/lib/chat/user-text";
15
+ import { splitProse, splitUserText } from "@/lib/chat/user-text";
16
+ import { inAppSessionId } from "@/lib/session-url";
16
17
  import { CodeBlock } from "./Code";
17
18
  import { CopyButton } from "./CopyButton";
18
19
  import { ImageThumb } from "./ImageThumb";
19
20
  import { Markdown } from "./Markdown";
21
+ import { openSessionLink } from "./session-link";
20
22
  import { UndoButton } from "./Undo";
21
23
  import { useRowState } from "./rowState";
22
24
 
23
25
  type Of<K extends ChatItem["kind"]> = Extract<ChatItem, { kind: K }>;
24
26
 
27
+ function UserLink({ href }: { href: string }) {
28
+ const id =
29
+ typeof window === "undefined"
30
+ ? null
31
+ : inAppSessionId(href, window.location.origin);
32
+ return (
33
+ <a
34
+ href={href}
35
+ target={id ? undefined : "_blank"}
36
+ rel={id ? undefined : "noopener noreferrer"}
37
+ onClick={id ? (e) => openSessionLink(e, id) : undefined}
38
+ className="font-medium [overflow-wrap:anywhere] underline underline-offset-2"
39
+ >
40
+ {href}
41
+ </a>
42
+ );
43
+ }
44
+
25
45
  function Prose({ text }: { text: string }) {
26
46
  return (
27
47
  <>
28
- {splitInlineCode(text).map((p, i) =>
29
- p.code ? (
48
+ {splitProse(text).map((p, i) =>
49
+ p.kind === "code" ? (
30
50
  <code
31
51
  key={i}
32
52
  className="bg-foreground/[0.06] rounded px-1 py-0.5 font-mono text-[0.9em]"
33
53
  >
34
54
  {p.text}
35
55
  </code>
56
+ ) : p.kind === "link" ? (
57
+ <UserLink key={i} href={p.text} />
36
58
  ) : (
37
59
  <span key={i}>{p.text}</span>
38
60
  )
@@ -0,0 +1,10 @@
1
+ import type { MouseEvent } from "react";
2
+ import { hrefWithSession } from "@/lib/session-url";
3
+
4
+ // Opens the session through its address, the way a shared link does.
5
+ export function openSessionLink(e: MouseEvent<HTMLAnchorElement>, id: string) {
6
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
7
+ e.preventDefault();
8
+ window.history.pushState(null, "", hrefWithSession(location.href, id));
9
+ window.dispatchEvent(new PopStateEvent("popstate"));
10
+ }
@@ -62,3 +62,55 @@ export function splitInlineCode(
62
62
  if (last < text.length) out.push({ code: false, text: text.slice(last) });
63
63
  return out;
64
64
  }
65
+
66
+ export type ProsePart =
67
+ | { kind: "text"; text: string }
68
+ | { kind: "code"; text: string }
69
+ | { kind: "link"; text: string };
70
+
71
+ const URL_RE = /https?:\/\/[^\s<>"'`]+/gi;
72
+ const TRAILING = /[.,;:!?]$/;
73
+
74
+ // Punctuation that ends a sentence, or a closing paren with no opener in the
75
+ // URL, belongs to the text around it.
76
+ function trimUrl(url: string): string {
77
+ for (;;) {
78
+ if (TRAILING.test(url)) url = url.slice(0, -1);
79
+ else if (url.endsWith(")") && count(url, ")") > count(url, "("))
80
+ url = url.slice(0, -1);
81
+ else return url;
82
+ }
83
+ }
84
+
85
+ function count(s: string, ch: string): number {
86
+ return s.split(ch).length - 1;
87
+ }
88
+
89
+ // http(s) URLs in plain text, split out so they can be links.
90
+ export function splitLinks(text: string): { link: boolean; text: string }[] {
91
+ const out: { link: boolean; text: string }[] = [];
92
+ let last = 0;
93
+ for (const m of text.matchAll(URL_RE)) {
94
+ const url = trimUrl(m[0]);
95
+ if (!/^https?:\/\/[^/?#]+/i.test(url)) continue;
96
+ if (m.index > last)
97
+ out.push({ link: false, text: text.slice(last, m.index) });
98
+ out.push({ link: true, text: url });
99
+ last = m.index + url.length;
100
+ }
101
+ if (last < text.length) out.push({ link: false, text: text.slice(last) });
102
+ return out;
103
+ }
104
+
105
+ // Prose split into code spans, links and the text between. Links are only
106
+ // looked for outside code spans.
107
+ export function splitProse(text: string): ProsePart[] {
108
+ return splitInlineCode(text).flatMap((p): ProsePart[] =>
109
+ p.code
110
+ ? [{ kind: "code", text: p.text }]
111
+ : splitLinks(p.text).map((l) => ({
112
+ kind: l.link ? "link" : "text",
113
+ text: l.text,
114
+ }))
115
+ );
116
+ }
@@ -26,6 +26,18 @@ export function hrefWithSession(href: string, id: string | null): string {
26
26
  return url.pathname + url.search + url.hash;
27
27
  }
28
28
 
29
+ /** The session a link to this same instance opens, or null for any other link. */
30
+ export function inAppSessionId(href: string, origin: string): string | null {
31
+ let url: URL;
32
+ try {
33
+ url = new URL(href);
34
+ } catch {
35
+ return null;
36
+ }
37
+ if (url.origin !== origin || url.pathname !== "/") return null;
38
+ return sessionIdFromHref(href);
39
+ }
40
+
29
41
  export function sessionLink(origin: string, id: string): string {
30
42
  return `${origin}/?${SESSION_PARAM}=${encodeURIComponent(id)}`;
31
43
  }
@@ -189,7 +189,10 @@ const workspaceOf = (projectId: string) =>
189
189
  // gives its slot back until it resumes. A quick change
190
190
  // runs no agent, so it holds none. Without a
191
191
  // workspace, the project is the scope.
192
- function runningIn(scope: { workspaceId: string | null; projectId: string }) {
192
+ function runningIn(
193
+ scope: { workspaceId: string | null; projectId: string },
194
+ forSlots = false
195
+ ) {
193
196
  const where = scope.workspaceId
194
197
  ? `p.workspace_id = @ws`
195
198
  : `p.id = @project AND p.workspace_id IS NULL`;
@@ -199,6 +202,7 @@ function runningIn(scope: { workspaceId: string | null; projectId: string }) {
199
202
  `SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
200
203
  WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
201
204
  AND (s.pause_state IS NULL OR s.pause_state != 'paused')
205
+ ${forSlots ? `AND NOT (COALESCE(s.plan_only, 0) = 1 AND COALESCE(s.plan_phase, '') != 'implementing')` : ""}
202
206
  AND s.task_status IN (${IN_FLIGHT.map((s) => `'${s}'`).join(", ")})`
203
207
  )
204
208
  .all({ ws: scope.workspaceId, project: scope.projectId }) as {
@@ -214,7 +218,8 @@ function startingIn(workspaceId: string): number {
214
218
  db
215
219
  .prepare(
216
220
  `SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
217
- WHERE q.status = 'starting' AND p.workspace_id = ?`
221
+ WHERE q.status = 'starting' AND p.workspace_id = ?
222
+ AND q.plan_only IS NULL`
218
223
  )
219
224
  .get(workspaceId) as { n: number }
220
225
  ).n
@@ -315,7 +320,8 @@ const waitingIn = (workspaceId: string) =>
315
320
  .prepare(
316
321
  `SELECT COUNT(*) AS n FROM task_queue q JOIN projects p ON p.id = q.project_id
317
322
  WHERE q.status = 'queued' AND p.workspace_id = ?
318
- AND q.after_task IS NULL AND q.after_any IS NULL`
323
+ AND q.after_task IS NULL AND q.after_any IS NULL
324
+ AND q.plan_only IS NULL`
319
325
  )
320
326
  .get(workspaceId) as { n: number }
321
327
  ).n;
@@ -344,9 +350,10 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
344
350
  } else if (after)
345
351
  afterTask = resolveAfter(after, req.originWorkspaceId ?? workspaceId).id;
346
352
  const full =
353
+ !req.planOnly &&
347
354
  limit !== null &&
348
355
  !!workspaceId &&
349
- (runningIn({ workspaceId, projectId: project.id }).length +
356
+ (runningIn({ workspaceId, projectId: project.id }, true).length +
350
357
  startingIn(workspaceId) >=
351
358
  limit ||
352
359
  waitingIn(workspaceId) > 0);
@@ -360,6 +367,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
360
367
  const loaded =
361
368
  !waits &&
362
369
  !full &&
370
+ !req.planOnly &&
363
371
  !!req.originWorkspaceId &&
364
372
  loadHeldStart(project.id, req.prompt, setting);
365
373
  if (!waits && !full && !loaded) return null;
@@ -523,9 +531,10 @@ export const queuedView = (id: string) =>
523
531
 
524
532
  // Why an automatic start has to wait, or null. A start now by a person
525
533
  // isn't held.
526
- function autoHold(workspaceId: string | null): Hold | null {
534
+ function autoHold(workspaceId: string | null, planOnly = false): Hold | null {
527
535
  if (workspaceId && isPaused(workspaceId))
528
536
  return { code: "orch_paused", text: "Waiting: the orchestrator is paused" };
537
+ if (planOnly) return null;
529
538
  const window = brakesEnabled() ? windowRefusal(readUsage()) : null;
530
539
  return window ? { code: "window", text: `Waiting: ${window}` } : null;
531
540
  }
@@ -609,7 +618,7 @@ async function startRow(
609
618
  return "failed";
610
619
  }
611
620
  if (!opts.force) {
612
- const held = autoHold(workspaceId);
621
+ const held = autoHold(workspaceId, !!r.plan_only);
613
622
  if (held) {
614
623
  if (r.note !== held.text)
615
624
  patch(r.id, { note: held.text, wait_code: held.code });
@@ -621,7 +630,8 @@ async function startRow(
621
630
  if (
622
631
  workspaceId &&
623
632
  limit !== null &&
624
- runningIn({ workspaceId, projectId: r.project_id }).length +
633
+ !r.plan_only &&
634
+ runningIn({ workspaceId, projectId: r.project_id }, true).length +
625
635
  startingIn(workspaceId) >=
626
636
  limit
627
637
  )
@@ -643,7 +653,7 @@ async function startRow(
643
653
  }
644
654
  if (!claimed) return "held";
645
655
  const placed = opts.placement ?? null;
646
- if (!opts.force)
656
+ if (!opts.force && !r.plan_only)
647
657
  recordAutoStart(placed?.hostId ?? LOCAL, !!opts.heavy, Date.now());
648
658
  // A linked machine's start never sees this machine's held messages: they
649
659
  // go with the brief it is sent.
@@ -747,7 +757,7 @@ function groupFor(
747
757
  const scope = { workspaceId, projectId };
748
758
  return {
749
759
  inFlight:
750
- runningIn(scope).length +
760
+ runningIn(scope, true).length +
751
761
  (workspaceId ? (reserved.get(workspaceId) ?? 0) : 0) +
752
762
  startingRows,
753
763
  limit: limitOf(workspaceId) ?? Number.POSITIVE_INFINITY,
@@ -814,8 +824,9 @@ function candidateOf(
814
824
  heavy: heavyNeeds(needs),
815
825
  needsUnmet: !remote && !meetsNeeds(needs, machineCapabilities()),
816
826
  remote,
827
+ planOnly: !!r.plan_only,
817
828
  before,
818
- auto: autoHold(r.workspace_id),
829
+ auto: autoHold(r.workspace_id, !!r.plan_only),
819
830
  any,
820
831
  },
821
832
  };
@@ -82,6 +82,9 @@ export interface Candidate {
82
82
  needsUnmet: boolean;
83
83
  // Starts on another machine: this one's load doesn't matter to it.
84
84
  remote: boolean;
85
+ // A plan-only task before approval: it reads code and writes one file, so
86
+ // load, spacing and the slot limit don't apply. Pause and `after` still do.
87
+ planOnly?: boolean;
85
88
  // `after X` hold, from the caller (it needs the database).
86
89
  before: Hold | null;
87
90
  // Orchestrator Pause and the usage window, which hold after `after`.
@@ -127,11 +130,12 @@ const seconds = (ms: number) => Math.max(1, Math.ceil(ms / 1000));
127
130
 
128
131
  // The machine holds, in order. Null: it may start here now.
129
132
  export function machineHold(
130
- c: Pick<Candidate, "heavy" | "needsUnmet" | "remote" | "priority">,
133
+ c: Pick<Candidate, "heavy" | "needsUnmet" | "remote" | "priority"> &
134
+ Partial<Pick<Candidate, "planOnly">>,
131
135
  m: MachineView,
132
136
  now: number
133
137
  ): Hold | null {
134
- if (c.remote) return null;
138
+ if (c.remote || c.planOnly) return null;
135
139
  if (c.needsUnmet) return { code: "needs", text: `Waiting: needs a Mac` };
136
140
  if (!m.enabled) return null;
137
141
  if (m.reading)
@@ -226,7 +230,7 @@ export function planStarts(
226
230
  priority: c.priority,
227
231
  });
228
232
  if (hold) return void plan.explain.set(c.id, view(hold));
229
- if (inFlight >= group.limit)
233
+ if (!c.planOnly && inFlight >= group.limit)
230
234
  return void plan.explain.set(
231
235
  c.id,
232
236
  view({
@@ -240,8 +244,8 @@ export function planStarts(
240
244
  if (free) used.add(free);
241
245
  plan.consume.set(c.id, free);
242
246
  }
243
- inFlight += 1;
244
- if (!c.remote) {
247
+ if (!c.planOnly) inFlight += 1;
248
+ if (!c.remote && !c.planOnly) {
245
249
  m.lastAutoStartAt = now;
246
250
  if (c.heavy) {
247
251
  m.lastHeavyStartAt = now;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.580",
3
+ "version": "0.3.582",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"