@saadnvd1/agent-os 0.3.483 → 0.3.484

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/README.md CHANGED
@@ -363,6 +363,24 @@ fired `mid_turn` or `between_turns`, and the context before and after), so cost
363
363
  menu, dialog or the command list is open (those close first). On a phone the
364
364
  Stop button does the same. A stopped turn keeps what it wrote.
365
365
 
366
+ Stop ends the turn even while the model is still thinking, before any tool
367
+ call: the turn is interrupted, and if it hasn't ended within a few seconds the
368
+ agent process is ended and restarted for the next message (not if the chat is
369
+ waiting on an approval or question, or if a new message has started a new
370
+ turn meanwhile). The conversation and
371
+ worktree are kept, and whatever is sent next (yours, another agent's, the
372
+ orchestrator's) runs as the next turn, never queued behind the stopped one.
373
+ The UI, the orchestrator's `stop` tool and `aos stop` all take this one path
374
+ and report "stopped" or a real error.
375
+
376
+ A turn that has produced nothing (no token, tool call or result) for 8 minutes
377
+ shows as **Stalled** in the session list, and its workspace's orchestrator gets
378
+ one event naming the session, how long it has been silent and its last action.
379
+ It fires once per stall and starts over when the session does something. A
380
+ foreground tool call gets four times as long, and a session waiting on a
381
+ background command it started isn't flagged. Set the time (2 to 120 minutes, 0 for off)
382
+ in Settings → Workspaces → Stall alerts.
383
+
366
384
  The access picker under the composer sets what the agent may do on its own:
367
385
  **Ask first** (an approval card in the chat for anything not already allowed),
368
386
  **Accept edits** (edits files, asks before other commands) or **Full access**
@@ -852,6 +870,7 @@ aos schedule run <name> # run a schedule now
852
870
  aos schedule add --session <s> --every 30m "prompt" # check in on a session
853
871
  aos notify "text" # push a message to your phone
854
872
  aos heavy -- npm test # run it now; says first what else heavy is running
873
+ aos stop <session> # stop its turn now; the next message runs as a new turn
855
874
  aos done <session> # finished: merge through the gates, archive
856
875
  aos done --all-idle # the same for every idle session around you
857
876
  aos docs [query] # LumifyHub pages, when the workspace is linked
@@ -0,0 +1,26 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { resolveSession } from "@/lib/bus";
3
+ import { getDoneTarget } from "@/lib/done";
4
+ import { inScope, scopeOf } from "@/lib/done/bulk";
5
+ import { stopSession } from "@/lib/sessions/stop";
6
+
7
+ // `aos stop <session>`, from inside a session: ends the turn in progress
8
+ // (or the terminal agent) and keeps the conversation and worktree. Reaches
9
+ // only the caller's workspace (or its project when it has none).
10
+ export async function POST(request: NextRequest) {
11
+ try {
12
+ const { from = null, session } = await request.json();
13
+ if (typeof from !== "string" || !from)
14
+ throw new Error("aos stop runs from inside an AgentOS session");
15
+ const caller = getDoneTarget(from);
16
+ const target = resolveSession(String(session ?? ""));
17
+ if (!inScope(scopeOf(caller), target))
18
+ throw new Error(
19
+ `${target.name} isn't in ${caller.name}'s workspace or project; aos stop reaches only those`
20
+ );
21
+ return NextResponse.json({ summary: await stopSession(target) });
22
+ } catch (error) {
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ return NextResponse.json({ error: message }, { status: 409 });
25
+ }
26
+ }
@@ -0,0 +1,18 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { setStallMinutes, stallMinutes } from "@/lib/chat/stall";
3
+
4
+ // How long a chat turn may be silent before it's flagged stalled and its
5
+ // workspace's orchestrator is told (lib/chat/stall); 0 for never.
6
+ export async function GET() {
7
+ return NextResponse.json({ minutes: stallMinutes() });
8
+ }
9
+
10
+ export async function PUT(request: NextRequest) {
11
+ try {
12
+ const body = await request.json();
13
+ return NextResponse.json({ minutes: setStallMinutes(body.minutes) });
14
+ } catch (error) {
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ return NextResponse.json({ error: message }, { status: 400 });
17
+ }
18
+ }
@@ -41,6 +41,12 @@ export function RowBadge({ row }: { row: SidebarRow }) {
41
41
  </span>
42
42
  );
43
43
  }
44
+ if (row.status?.stalled)
45
+ return (
46
+ <span className="rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-amber-700 dark:text-amber-400">
47
+ Stalled
48
+ </span>
49
+ );
44
50
  if (row.unread) return <UnreadDot />;
45
51
  return null;
46
52
  }
@@ -9,6 +9,7 @@ export interface SessionStatus {
9
9
  asks?: number;
10
10
  need?: SessionNeed | null;
11
11
  unread?: boolean;
12
+ stalled?: boolean;
12
13
  }
13
14
 
14
15
  export interface SessionListProps {
@@ -0,0 +1,101 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Switch } from "@/components/ui/switch";
8
+ import { useStallQuery, useUpdateStall } from "@/data/stall";
9
+ import {
10
+ DEFAULT_STALL_MINUTES,
11
+ MAX_STALL_MINUTES,
12
+ MIN_STALL_MINUTES,
13
+ } from "@/lib/chat/stall-limits";
14
+
15
+ // How long a turn may produce nothing before it's flagged stalled and the
16
+ // workspace's orchestrator is told: app-wide.
17
+ export function StallForm() {
18
+ const { data, isPending, isError, error } = useStallQuery();
19
+ if (isPending)
20
+ return <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />;
21
+ if (isError)
22
+ return <p className="text-destructive text-sm">{error.message}</p>;
23
+ return <Form key={data.minutes} saved={data.minutes} />;
24
+ }
25
+
26
+ function Form({ saved }: { saved: number }) {
27
+ const update = useUpdateStall();
28
+ const [on, setOn] = useState(saved > 0);
29
+ const [text, setText] = useState(
30
+ String(saved > 0 ? saved : DEFAULT_STALL_MINUTES)
31
+ );
32
+ const n = Number(text);
33
+ const valid =
34
+ Number.isInteger(n) && n >= MIN_STALL_MINUTES && n <= MAX_STALL_MINUTES;
35
+ const minutes = on ? n : 0;
36
+ const error =
37
+ on && !valid
38
+ ? `Between ${MIN_STALL_MINUTES} and ${MAX_STALL_MINUTES} minutes`
39
+ : null;
40
+ const dirty = !error && minutes !== saved;
41
+
42
+ return (
43
+ <form
44
+ onSubmit={(e) => {
45
+ e.preventDefault();
46
+ if (error) return;
47
+ update.mutate(minutes, {
48
+ onSuccess: () => toast.success("Stall alert saved"),
49
+ onError: (err) => toast.error(err.message),
50
+ });
51
+ }}
52
+ className="bg-muted/40 space-y-3 rounded-lg px-3 py-3"
53
+ >
54
+ <h3 className="text-sm font-medium">Stalled turns</h3>
55
+ <p className="text-muted-foreground text-sm">
56
+ A turn that produces nothing for this long (no text, tool call or
57
+ result) shows as stalled, and the workspace&apos;s orchestrator is told
58
+ once. A long command that is still printing, or a background command the
59
+ agent is waiting on, isn&apos;t a stall.
60
+ </p>
61
+ <label className="flex min-h-11 items-center justify-between gap-3 text-sm md:min-h-9">
62
+ Flag stalled turns
63
+ <Switch
64
+ checked={on}
65
+ onCheckedChange={setOn}
66
+ aria-label="Flag stalled turns"
67
+ />
68
+ </label>
69
+ {on && (
70
+ <label className="flex items-center justify-between gap-3 text-sm">
71
+ <span className="text-muted-foreground">After</span>
72
+ <span className="flex items-center gap-2">
73
+ <Input
74
+ type="number"
75
+ inputMode="numeric"
76
+ min={MIN_STALL_MINUTES}
77
+ max={MAX_STALL_MINUTES}
78
+ value={text}
79
+ onChange={(e) => setText(e.target.value)}
80
+ aria-label="Stall threshold, minutes"
81
+ aria-invalid={!!error}
82
+ className="h-11 w-24 text-base sm:h-9 md:text-sm"
83
+ />
84
+ <span className="text-muted-foreground">minutes</span>
85
+ </span>
86
+ </label>
87
+ )}
88
+ {error && <p className="text-destructive text-xs">{error}</p>}
89
+ <div className="flex justify-end">
90
+ <Button
91
+ type="submit"
92
+ size="sm"
93
+ className="h-11 sm:h-8"
94
+ disabled={!!error || !dirty || update.isPending}
95
+ >
96
+ Save
97
+ </Button>
98
+ </div>
99
+ </form>
100
+ );
101
+ }
@@ -5,6 +5,7 @@ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
5
5
  import { TaskLimitForm } from "./TaskLimitForm";
6
6
  import { RulesForm } from "./RulesForm";
7
7
  import { FreshStartForm } from "./FreshStartForm";
8
+ import { StallForm } from "./StallForm";
8
9
  import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
9
10
  import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
10
11
 
@@ -53,6 +54,7 @@ export function WorkspaceSettings() {
53
54
  </section>
54
55
  ))}
55
56
  <FreshStartForm />
57
+ <StallForm />
56
58
  <AwayWindowForm />
57
59
  </div>
58
60
  );
@@ -0,0 +1,33 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+
3
+ // How long a turn may be silent before it's stalled (lib/chat/stall).
4
+ export const stallKeys = { all: ["stall"] as const };
5
+
6
+ async function json<T>(res: Response): Promise<T> {
7
+ const data = await res.json().catch(() => ({}));
8
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
9
+ return data;
10
+ }
11
+
12
+ export function useStallQuery() {
13
+ return useQuery({
14
+ queryKey: stallKeys.all,
15
+ queryFn: async () => json<{ minutes: number }>(await fetch("/api/stall")),
16
+ staleTime: 60000,
17
+ });
18
+ }
19
+
20
+ export function useUpdateStall() {
21
+ const queryClient = useQueryClient();
22
+ return useMutation({
23
+ mutationFn: async (minutes: number) =>
24
+ json<{ minutes: number }>(
25
+ await fetch("/api/stall", {
26
+ method: "PUT",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({ minutes }),
29
+ })
30
+ ),
31
+ onSuccess: (data) => queryClient.setQueryData(stallKeys.all, data),
32
+ });
33
+ }
@@ -1,23 +1,48 @@
1
- import type { ChatState } from "./events";
1
+ import type { ChatItem, ChatState } from "./events";
2
2
  import { formatElapsed } from "./elapsed";
3
3
  import { registry, type Live } from "./registry";
4
+ import { stallThresholdMs } from "./stall";
4
5
  import { listItems } from "./store";
5
6
  import type { WorkerEvent } from "./worker/protocol";
6
7
 
8
+ const lastWhat = (i: ChatItem): string =>
9
+ i.kind === "tool"
10
+ ? i.status === "running"
11
+ ? i.title
12
+ : `finished ${i.title}`
13
+ : i.kind === "assistant"
14
+ ? "writing a reply"
15
+ : i.kind === "reasoning"
16
+ ? "thinking"
17
+ : i.kind === "user"
18
+ ? "your message"
19
+ : i.kind;
20
+
7
21
  // Keeps a conversation's "what it's doing now" current from its events.
8
22
  export function track(live: Live, e: WorkerEvent): void {
9
23
  const a = live.activity;
10
24
  if (e.type === "state") {
11
- if (e.state === "running" && !a.turnStartedAt) a.turnStartedAt = Date.now();
25
+ if (e.state === "running" && !a.turnStartedAt) {
26
+ a.turnStartedAt = Date.now();
27
+ a.lastAt = a.turnStartedAt;
28
+ a.lastWhat = "starting the turn";
29
+ }
12
30
  if (e.state === "idle") {
13
31
  a.turnStartedAt = undefined;
32
+ a.lastAt = a.lastWhat = undefined;
14
33
  a.tools.clear();
15
34
  }
16
35
  } else if (e.type === "item" && e.item.kind === "turn_end") {
17
36
  // A queued message starts the next turn with no idle in between.
18
37
  a.turnStartedAt = undefined;
38
+ a.lastAt = a.lastWhat = undefined;
19
39
  a.tools.clear();
20
- } else if (e.type === "item" && e.item.kind === "tool") {
40
+ } else if (e.type === "item" || e.type === "delta") {
41
+ a.lastAt = Date.now();
42
+ a.lastWhat = e.type === "delta" ? "writing" : lastWhat(e.item);
43
+ }
44
+ if (e.type === "item" && e.item.kind === "user") a.sends = (a.sends ?? 0) + 1;
45
+ if (e.type === "item" && e.item.kind === "tool") {
21
46
  if (e.item.status === "running")
22
47
  a.tools.set(e.item.id, { label: e.item.title, since: e.item.createdAt });
23
48
  else a.tools.delete(e.item.id);
@@ -35,6 +60,11 @@ export function restoreActivity(sessionId: string, state: ChatState) {
35
60
  activity.turnStartedAt = items.findLast(
36
61
  (i) => i.kind === "user"
37
62
  )?.createdAt;
63
+ if (state !== "idle") {
64
+ const last = items.at(-1);
65
+ activity.lastAt = last?.createdAt ?? activity.turnStartedAt;
66
+ activity.lastWhat = last ? lastWhat(last) : undefined;
67
+ }
38
68
  for (const i of items) {
39
69
  if (i.kind === "tool" && i.status === "running" && state !== "idle")
40
70
  activity.tools.set(i.id, { label: i.title, since: i.createdAt });
@@ -44,11 +74,50 @@ export function restoreActivity(sessionId: string, state: ChatState) {
44
74
  return activity;
45
75
  }
46
76
 
77
+ export interface Stall {
78
+ silentMs: number;
79
+ // What it last did.
80
+ last: string;
81
+ // When that was: one stall per value, so a condition keyed on it fires
82
+ // once and a new one starts only after activity resumes.
83
+ since: number;
84
+ }
85
+
86
+ // A turn that has produced nothing (no token, item or result) for the
87
+ // threshold. Never one waiting on an answer, nor one with a background task
88
+ // running: a command it started can legitimately be quiet.
89
+ const TOOL_STALL_FACTOR = 4;
90
+
91
+ export function stallOf(
92
+ live: Live,
93
+ now: number,
94
+ thresholdMs: number
95
+ ): Stall | null {
96
+ const a = live.activity;
97
+ if (live.state !== "running" || thresholdMs <= 0 || a.tasks.size > 0)
98
+ return null;
99
+ // A foreground tool call (a long test run) says nothing while it works:
100
+ // it gets a much longer allowance before it counts as stuck.
101
+ if (a.tools.size > 0) thresholdMs *= TOOL_STALL_FACTOR;
102
+ const since = a.lastAt ?? a.turnStartedAt;
103
+ if (!since || now - since < thresholdMs) return null;
104
+ return { silentMs: now - since, last: a.lastWhat ?? "unknown", since };
105
+ }
106
+
107
+ // Whether a chat on this machine is stalled now, by the app-wide threshold.
108
+ export function chatStall(sessionId: string): Stall | null {
109
+ const live = registry.live.get(sessionId);
110
+ return live ? stallOf(live, Date.now(), stallThresholdMs()) : null;
111
+ }
112
+
47
113
  // One line on what a chat session is doing, for the session list and
48
114
  // notifications: the step running now and since when.
49
- export function chatActivity(
50
- sessionId: string
51
- ): { label: string; since?: number; background: number } | null {
115
+ export function chatActivity(sessionId: string): {
116
+ label: string;
117
+ since?: number;
118
+ background: number;
119
+ stalled?: Stall;
120
+ } | null {
52
121
  const live = registry.live.get(sessionId);
53
122
  if (!live) return null;
54
123
  const a = live.activity;
@@ -57,6 +126,9 @@ export function chatActivity(
57
126
  return { label: "Needs your answer", background };
58
127
  if (live.state !== "running")
59
128
  return background ? { label: "Idle", background } : null;
129
+ const stalled = stallOf(live, Date.now(), stallThresholdMs()) ?? undefined;
130
+ if (stalled)
131
+ return { label: "Stalled", since: stalled.since, background, stalled };
60
132
  const tool = [...a.tools.values()].at(-1);
61
133
  return tool
62
134
  ? { label: tool.label, since: tool.since, background }
@@ -71,6 +143,8 @@ export function chatActivityLine(sessionId: string): string | null {
71
143
  ? `${a.background} running in background`
72
144
  : "";
73
145
  if (a.label === "Idle") return background || null;
146
+ if (a.stalled)
147
+ return `Stalled · no output for ${formatElapsed(a.stalled.silentMs)} · last: ${a.stalled.last}`;
74
148
  const parts = [a.label];
75
149
  if (a.since) parts.push(formatElapsed(Date.now() - a.since));
76
150
  if (background) parts.push(background);
@@ -354,7 +354,7 @@ export type ChatClientMessage =
354
354
  // `during`: the user message whose turn was running when it was tapped.
355
355
  | { type: "queue_send_now"; id: string; during?: string }
356
356
  | { type: "files"; reqId: string; query: string }
357
- | { type: "interrupt" }
357
+ | { type: "interrupt"; hard?: boolean }
358
358
  | { type: "set_model"; model: string }
359
359
  | { type: "set_access"; access: ChatAccess }
360
360
  | { type: "set_plan"; plan: boolean }
@@ -34,6 +34,13 @@ export interface Live {
34
34
 
35
35
  export interface ChatActivity {
36
36
  turnStartedAt?: number;
37
+ // When the turn last produced anything (a token, an item, a tool result)
38
+ // and what that was; unset between turns. Silence from here is a stall.
39
+ lastAt?: number;
40
+ lastWhat?: string;
41
+ // Messages the user side has sent this conversation: a change while a stop
42
+ // waits means a new turn began, which the stop must leave alone.
43
+ sends?: number;
37
44
  // Running tool calls by id: their label and when they started.
38
45
  tools: Map<string, { label: string; since: number }>;
39
46
  // Running background tasks (not housekeeping ones).
@@ -697,6 +697,8 @@ export async function undoChat(
697
697
  reply({ type: "undone", from, text: target.text });
698
698
  }
699
699
 
700
+ // A plain Esc: ends the turn the way the agent would, and leaves a queue
701
+ // behind it alone. Stop (./stop stopChatTurn) is the one that must end it.
700
702
  export async function interruptChat(sessionId: string): Promise<void> {
701
703
  registry.live.get(sessionId)?.worker.command({ type: "interrupt" });
702
704
  }
@@ -16,7 +16,6 @@ import {
16
16
  chatToolBody,
17
17
  deleteQueuedChat,
18
18
  editQueuedChat,
19
- interruptChat,
20
19
  moveQueuedChat,
21
20
  refreshCapabilities,
22
21
  respondChat,
@@ -29,6 +28,7 @@ import {
29
28
  undoChat,
30
29
  watchChat,
31
30
  } from "./runner";
31
+ import { stopChatTurn } from "./stop";
32
32
  import { clientSend } from "./client-send";
33
33
  import { searchChat } from "./search";
34
34
  import { DEMO_CHAT_READS, handleDemoChat } from "./demo";
@@ -175,7 +175,7 @@ function onChatMessage(
175
175
  .catch(() => [])
176
176
  .then(answer)
177
177
  );
178
- } else if (msg.type === "interrupt") run(interruptChat(sessionId));
178
+ } else if (msg.type === "interrupt") run(stopChatTurn(sessionId));
179
179
  else if (msg.type === "set_model") run(setChatModel(sessionId, msg.model));
180
180
  else if (msg.type === "set_access") run(setChatAccess(sessionId, msg.access));
181
181
  else if (msg.type === "set_plan") run(setChatPlan(sessionId, !!msg.plan));
@@ -0,0 +1,5 @@
1
+ // The range the stall alert may take (lib/chat/stall), shared with the
2
+ // settings form.
3
+ export const DEFAULT_STALL_MINUTES = 8;
4
+ export const MIN_STALL_MINUTES = 2;
5
+ export const MAX_STALL_MINUTES = 120;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Stall alerts: a chat turn silent for this long (no token, tool call or
3
+ * result) is flagged "stalled" in the UI and reported once to its workspace's
4
+ * orchestrator (lib/orchestrator/conditions). App-wide, in Settings →
5
+ * Workspaces. 0 is off.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import {
10
+ DEFAULT_STALL_MINUTES,
11
+ MAX_STALL_MINUTES,
12
+ MIN_STALL_MINUTES,
13
+ } from "./stall-limits";
14
+
15
+ export const STALL_KEY = "chat.stall_minutes";
16
+
17
+ export function stallMinutes(): number {
18
+ const row = db
19
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
20
+ .get(STALL_KEY) as { value: string } | undefined;
21
+ const n = row ? Number(row.value) : DEFAULT_STALL_MINUTES;
22
+ return Number.isInteger(n) && n >= 0 ? n : DEFAULT_STALL_MINUTES;
23
+ }
24
+
25
+ export const stallThresholdMs = () => stallMinutes() * 60_000;
26
+
27
+ // 0 (off) or a whole number of minutes in range. Throws a plain message
28
+ // otherwise.
29
+ export function checkStallMinutes(value: unknown): number {
30
+ if (value === 0) return 0;
31
+ if (
32
+ typeof value !== "number" ||
33
+ !Number.isInteger(value) ||
34
+ value < MIN_STALL_MINUTES ||
35
+ value > MAX_STALL_MINUTES
36
+ )
37
+ throw new Error(
38
+ `The stall alert is off or between ${MIN_STALL_MINUTES} and ${MAX_STALL_MINUTES} minutes`
39
+ );
40
+ return value;
41
+ }
42
+
43
+ export function setStallMinutes(value: unknown): number {
44
+ const minutes = checkStallMinutes(value);
45
+ db.prepare(
46
+ `INSERT INTO settings (key, value) VALUES (?, ?)
47
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
48
+ ).run(STALL_KEY, String(minutes));
49
+ return minutes;
50
+ }
package/lib/chat/stop.ts CHANGED
@@ -68,3 +68,70 @@ export async function stopChatAtTurnEnd(
68
68
  await sleep(POLL_MS);
69
69
  }
70
70
  }
71
+
72
+ export type TurnStop =
73
+ | { stopped: true; killed: boolean }
74
+ | { stopped: false; reason: string };
75
+
76
+ export interface TurnStopOptions {
77
+ // How long the turn gets to end after the interrupt before its agent is
78
+ // killed.
79
+ graceMs?: number;
80
+ sleep?: (ms: number) => Promise<void>;
81
+ }
82
+
83
+ const TURN_POLL_MS = 250;
84
+ // Idle has to hold this long: the CLI answers an interruption with a short
85
+ // turn of its own, which the host cuts short, and that must be over too.
86
+ const SETTLE_MS = 2500;
87
+
88
+ // The one way every caller (the stop button, the orchestrator's stop, aos
89
+ // stop, a restack) ends the turn in progress. The turn is interrupted, even
90
+ // while the model is thinking before any tool call; if it hasn't ended within
91
+ // the grace period the agent is killed. The conversation and worktree are
92
+ // kept, and the next message runs as the next turn.
93
+ export async function stopChatTurn(
94
+ sessionId: string,
95
+ { graceMs = 8000, sleep = defaultSleep }: TurnStopOptions = {}
96
+ ): Promise<TurnStop> {
97
+ let state;
98
+ try {
99
+ await registry.connecting.get(sessionId);
100
+ state = await chatStateNow(sessionId);
101
+ } catch {
102
+ return {
103
+ stopped: false,
104
+ reason: "its chat worker is running but isn't answering",
105
+ };
106
+ }
107
+ if (!state || state === "idle") return { stopped: true, killed: false };
108
+ // Its agent is asked a question: killing it would lose the card.
109
+ if (state === "waiting")
110
+ return {
111
+ stopped: false,
112
+ reason:
113
+ "it's waiting on an approval or question in its chat; answer or dismiss that first",
114
+ };
115
+ const sendsAtStop = registry.live.get(sessionId)?.activity.sends ?? 0;
116
+ registry.live
117
+ .get(sessionId)
118
+ ?.worker.command({ type: "interrupt", hard: true });
119
+ let idleFor = 0;
120
+ for (let waited = 0; waited < graceMs + SETTLE_MS; waited += TURN_POLL_MS) {
121
+ await sleep(TURN_POLL_MS);
122
+ // A message sent meanwhile has started a new turn: not ours to kill.
123
+ if ((registry.live.get(sessionId)?.activity.sends ?? 0) !== sendsAtStop)
124
+ return { stopped: true, killed: false };
125
+ const now = await chatStateNow(sessionId).catch(
126
+ () => "unreachable" as const
127
+ );
128
+ if (now === null || now === "idle") {
129
+ idleFor += TURN_POLL_MS;
130
+ if (idleFor >= SETTLE_MS) return { stopped: true, killed: false };
131
+ } else idleFor = 0;
132
+ if (waited >= graceMs && idleFor === 0) break;
133
+ }
134
+ stopChat(sessionId);
135
+ await waitForExit(sessionId);
136
+ return { stopped: true, killed: true };
137
+ }
@@ -50,6 +50,10 @@ import type { WorkerCommand, WorkerEvent } from "./protocol";
50
50
  // still counts as part of the stop: the agent starts what it queued the
51
51
  // moment a turn ends.
52
52
  const AFTER_STOP_MS = 2000;
53
+ // After a hard stop (Stop, the orchestrator's stop, aos stop), how long the
54
+ // agent's own wind-down turn is cut short and any sender's message reads as
55
+ // the next turn.
56
+ const HARD_STOP_MS = 30_000;
53
57
 
54
58
  // How long a retiring worker waits, once at rest with nothing queued, for
55
59
  // the agent's guess at the next message before it goes.
@@ -104,6 +108,7 @@ export class ChatHost {
104
108
  // should read next.
105
109
  private stopping: "turn" | "after" | null = null;
106
110
  private stoppedAt = 0;
111
+ private hardStopAt = 0;
107
112
  // On an older build than the server's (a "retire"): it closes at the next
108
113
  // turn boundary, once the agent holds no message it hasn't run and runs
109
114
  // no background work, so a current worker resumes the conversation.
@@ -327,6 +332,10 @@ export class ChatHost {
327
332
  this.settleCompactedLine(e.context.usedTokens);
328
333
  this.refreshIfFull(e.context.usedTokens);
329
334
  } else if (e.type === "turn_start") {
335
+ // The agent starting a turn of its own right after a hard stop
336
+ // (the CLI winds down with a reply to the interruption) is cut
337
+ // short, so what's sent next isn't queued behind it.
338
+ if (this.hardStopped()) void this.conversation.interrupt();
330
339
  this.turnsStarted++;
331
340
  this.turnStartedAt ??= Date.now();
332
341
  if (this.behind) this.behind--;
@@ -437,6 +446,7 @@ export class ChatHost {
437
446
  ): void {
438
447
  this.sent.add(m.id);
439
448
  this.currentTurn = m.id;
449
+ this.hardStopAt = 0;
440
450
  if (this.busy()) this.behind++;
441
451
  // Sent now, it stops the running turn, which still ends after this.
442
452
  if (!this.busy()) now = false;
@@ -562,9 +572,7 @@ export class ChatHost {
562
572
  // Another agent's message doesn't speak for the reader.
563
573
  if (
564
574
  this.stopping &&
565
- !cmd.from &&
566
- !cmd.peer &&
567
- !cmd.origin &&
575
+ (this.hardStopped() || (!cmd.from && !cmd.peer && !cmd.origin)) &&
568
576
  this.busy() &&
569
577
  !listQueue(this.session.id).length
570
578
  ) {
@@ -624,6 +632,12 @@ export class ChatHost {
624
632
  return;
625
633
  case "interrupt":
626
634
  await this.modeChange.catch(() => {});
635
+ if (cmd.hard) {
636
+ this.hardStopAt = Date.now();
637
+ this.behind = 0;
638
+ this.stopping = "turn";
639
+ return this.conversation.interrupt();
640
+ }
627
641
  if (!this.busy()) return this.conversation.interrupt();
628
642
  // What's queued goes on once the turn stops: it goes with the stop,
629
643
  // so it's what the agent reads next.
@@ -663,6 +677,10 @@ export class ChatHost {
663
677
  }
664
678
  }
665
679
 
680
+ private hardStopped(): boolean {
681
+ return this.hardStopAt > 0 && Date.now() - this.hardStopAt < HARD_STOP_MS;
682
+ }
683
+
666
684
  // A retiring worker goes once it's at a turn boundary: no turn running,
667
685
  // no message handed to the agent still to run, no background work (it
668
686
  // lives in the agent, and would be lost). With nothing queued it first
@@ -46,7 +46,8 @@ export type WorkerCommand =
46
46
  // Send the queue if no turn is running; a running one sends it as it ends.
47
47
  | { type: "drain" }
48
48
  | { type: "files"; reqId: string; query: string }
49
- | { type: "interrupt" }
49
+ // hard: a stop that must end the turn (and the agent's wind-down after it).
50
+ | { type: "interrupt"; hard?: boolean }
50
51
  | { type: "set_model"; model: string }
51
52
  | { type: "set_access"; access: ChatAccess }
52
53
  | { type: "set_plan"; plan: boolean }
@@ -6,9 +6,9 @@
6
6
 
7
7
  import { parseOverride, type ModelOverride } from "../models/resolve";
8
8
  import { sendMessage } from "../bus";
9
- import { chatState, interruptChat } from "../chat/runner";
9
+ import { chatState } from "../chat/runner";
10
+ import { stopSession } from "../sessions/stop";
10
11
  import { isRemoteHost } from "../hosts";
11
- import { endTmuxSession } from "../tmux/end";
12
12
  import { spawnSession } from "../agents/spawn";
13
13
  import {
14
14
  DEFAULT_START_VIEW,
@@ -226,11 +226,5 @@ export async function done(workspaceId: string, ref: string): Promise<string> {
226
226
  // Stops the agent and keeps everything else: a chat's turn is interrupted,
227
227
  // a terminal's tmux session ends. Its worktree and branch stay.
228
228
  export async function stop(workspaceId: string, ref: string): Promise<string> {
229
- const s = findWorkspaceSession(workspaceId, ref);
230
- if (s.view === "chat") {
231
- await interruptChat(s.id);
232
- return `Stopped ${s.name}'s turn.`;
233
- }
234
- await endTmuxSession(s.host_id, s.tmux_name);
235
- return `Stopped ${s.name}: its agent is ended, its worktree and branch are kept.`;
229
+ return stopSession(findWorkspaceSession(workspaceId, ref));
236
230
  }
@@ -109,6 +109,17 @@ function sessionConditions(
109
109
  true
110
110
  );
111
111
 
112
+ // Keyed on when it last did anything, so it is one event per stall and
113
+ // anything the session does after it makes the next stall a new one.
114
+ if (f.stalled)
115
+ add(
116
+ `stalled:${f.stalled.since}`,
117
+ `${who}: stalled: no output for ${Math.floor(f.stalled.silentMs / 60000)}m mid-turn (last: ${untrusted(f.name, f.stalled.last)}). Read it; stop it if it's stuck.`,
118
+ false,
119
+ false,
120
+ true
121
+ );
122
+
112
123
  // A quick change is the orchestrator's own: nothing to tell it.
113
124
  const shouldPR = (!!f.task || !!f.branch) && !f.quick;
114
125
  const finished = !!f.task && isFinished(f.task.state);
@@ -10,7 +10,7 @@ import { statusDetector } from "../status-detector";
10
10
  import { hostLink } from "../hosts/remote-api";
11
11
  import { peerStatus } from "../hosts/peer-sessions";
12
12
  import { chatState } from "../chat/runner";
13
- import { chatActivityLine } from "../chat/activity";
13
+ import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
14
14
  import { lastUserTask } from "../chat/store";
15
15
  import { needsYou } from "../needs-you";
16
16
  import { programSummary } from "../program-status/store";
@@ -32,6 +32,8 @@ export interface SessionFacts {
32
32
  // Really stopped on an answer: a chat's approval or question card, or a
33
33
  // terminal at its prompt. A chat that merely finished doesn't count.
34
34
  needsInput: boolean;
35
+ // A chat turn silent past the stall threshold (lib/chat/stall).
36
+ stalled?: Stall | null;
35
37
  // Last sign of life, in ms.
36
38
  lastActive: number;
37
39
  // Work that should end in a PR: a task, or a session on its own branch.
@@ -249,6 +251,8 @@ export async function sessionFacts(
249
251
  status,
250
252
  activity,
251
253
  needsInput,
254
+ stalled:
255
+ s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
252
256
  lastActive: Math.max(
253
257
  sqliteMs(s.updated_at),
254
258
  statusDetector.getTimestamp(s.tmux_name) * 1000,
@@ -75,7 +75,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
75
75
  stack_status: "One stack's items, PRs and progress.",
76
76
  land: "Merge a whole stack bottom-up, each PR through the sign-off gates at its current head.",
77
77
  drop: "Reject a task: close its PR and remove its worktree. Logged with the reason.",
78
- stop: "Stop a session's agent, keeping its worktree and branch.",
78
+ stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
79
79
  done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's archived. Not a rejection.",
80
80
  add_project:
81
81
  "Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
@@ -0,0 +1,28 @@
1
+ import { endTmuxSession } from "../tmux/end";
2
+ import { hostLink } from "../hosts/remote-api";
3
+ import { stopChatTurn } from "../chat/stop";
4
+ import type { Session } from "../db";
5
+
6
+ // Stops a session's agent and keeps everything else, the same for every
7
+ // caller (the orchestrator's stop tool, `aos stop`): a chat's turn ends
8
+ // whatever it is doing, a terminal's tmux session ends. The worktree, branch
9
+ // and conversation stay, and the next message runs as the next turn. Throws
10
+ // when the chat can't be shown to have stopped.
11
+ export async function stopSession(
12
+ s: Pick<Session, "id" | "name" | "view" | "host_id" | "tmux_name">
13
+ ): Promise<string> {
14
+ if (s.view === "chat") {
15
+ if (hostLink(s.host_id))
16
+ throw new Error(
17
+ `Couldn't stop ${s.name}: it runs on another machine; stop it there.`
18
+ );
19
+ const out = await stopChatTurn(s.id);
20
+ if (!out.stopped)
21
+ throw new Error(`Couldn't stop ${s.name}: ${out.reason}.`);
22
+ return out.killed
23
+ ? `Stopped ${s.name}: its agent didn't answer the interrupt, so it was ended. Its conversation and worktree are kept; your next message runs as its next turn.`
24
+ : `Stopped ${s.name}'s turn. Your next message runs as its next turn.`;
25
+ }
26
+ await endTmuxSession(s.host_id, s.tmux_name);
27
+ return `Stopped ${s.name}: its agent is ended, its worktree and branch are kept.`;
28
+ }
@@ -28,6 +28,7 @@ export interface RowStatus {
28
28
  status?: string;
29
29
  need?: SessionNeed | null;
30
30
  unread?: boolean;
31
+ stalled?: boolean;
31
32
  asks?: number;
32
33
  // A program's own message (OSC 7501): untrusted, plain text only.
33
34
  detail?: string | null;
@@ -230,6 +231,7 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
230
231
  a.status === b.status &&
231
232
  (a.need ?? null) === (b.need ?? null) &&
232
233
  !!a.unread === !!b.unread &&
234
+ !!a.stalled === !!b.stalled &&
233
235
  a.asks === b.asks &&
234
236
  (a.detail ?? null) === (b.detail ?? null)
235
237
  );
@@ -11,7 +11,7 @@
11
11
  import { db, stackQueries as q, type Session, type StackItemRow } from "../db";
12
12
  import { getDefaultBranch } from "../git";
13
13
  import { sendMessage } from "../bus";
14
- import { chatStateNow, interruptChat } from "../chat/runner";
14
+ import { stopChatTurn } from "../chat/stop";
15
15
  import { findPRStrict, run } from "../tasks/gh";
16
16
  import type { TaskPR } from "../tasks/state";
17
17
  import { expandHome } from "../tasks/session";
@@ -64,7 +64,7 @@ export const stackNotice = (
64
64
  origin: { kind: "system", label, body } as const,
65
65
  });
66
66
 
67
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
67
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
68
68
  // The note on an item left alone because its agent was still working.
69
69
  export const AGENT_BUSY = "Waiting for its agent to stop";
70
70
  // How long a chat's turn may take to stop after it's interrupted.
@@ -79,17 +79,11 @@ export async function interruptAgent(
79
79
  wait = sleep
80
80
  ): Promise<boolean> {
81
81
  if (session.view === "chat") {
82
- const now = () =>
83
- chatStateNow(session.id).catch(() => "unreachable" as const);
84
- const busy = (s: Awaited<ReturnType<typeof now>>) =>
85
- s === "running" || s === "waiting" || s === "unreachable";
86
- if (!busy(await now())) return true;
87
- await interruptChat(session.id);
88
- for (let waited = 0; waited < CHAT_STOP_WAIT_MS; waited += 500) {
89
- await wait(500);
90
- if (!busy(await now())) return true;
91
- }
92
- return false;
82
+ const out = await stopChatTurn(session.id, {
83
+ graceMs: CHAT_STOP_WAIT_MS,
84
+ sleep: wait,
85
+ });
86
+ return out.stopped;
93
87
  }
94
88
  const sent = await run(
95
89
  "tmux",
@@ -21,7 +21,7 @@ import {
21
21
  } from "../providers/registry";
22
22
  import { getDb, type Session } from "../db";
23
23
  import { chatState } from "../chat/runner";
24
- import { chatActivityLine } from "../chat/activity";
24
+ import { chatActivityLine, chatStall } from "../chat/activity";
25
25
  import {
26
26
  chatNeed,
27
27
  isUnread,
@@ -60,6 +60,8 @@ export interface SessionStatusResponse {
60
60
  asks?: number;
61
61
  need?: SessionNeed | null;
62
62
  unread?: boolean;
63
+ // A chat turn silent past the stall threshold (lib/chat/stall).
64
+ stalled?: boolean;
63
65
  // A program's own message (OSC 7501): untrusted, plain text only.
64
66
  detail?: string | null;
65
67
  progress?: number | null;
@@ -464,6 +466,7 @@ async function collect(): Promise<StatusSnapshot> {
464
466
  agentType: session.agent_type,
465
467
  need: chatNeed(session, state, asks),
466
468
  unread: isUnread(session, state),
469
+ ...(chatStall(session.id) && { stalled: true }),
467
470
  ...(session.role === "orchestrator" && { asks }),
468
471
  };
469
472
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.483",
3
+ "version": "0.3.484",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"