@saadnvd1/agent-os 0.3.744 → 0.3.748

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.
@@ -3,20 +3,33 @@
3
3
  import { ShieldOff } from "lucide-react";
4
4
  import { useApprovalsModeQuery } from "@/data/approvals";
5
5
  import { settingsUiActions } from "@/stores/settingsUi";
6
+ import {
7
+ Tooltip,
8
+ TooltipContent,
9
+ TooltipTrigger,
10
+ } from "@/components/ui/tooltip";
6
11
 
7
12
  // On screen for as long as No approvals mode is on; opens its Settings.
13
+ // Shrinks to the icon when the sidebar header is narrow.
8
14
  export function NoApprovalsBadge() {
9
15
  const { data } = useApprovalsModeQuery();
10
16
  if (!data?.on) return null;
11
17
  return (
12
- <button
13
- type="button"
14
- onClick={() => settingsUiActions.open("approvals")}
15
- title="No approvals mode is on: nothing waits for a person. Open settings."
16
- className="flex h-8 shrink-0 items-center gap-1 rounded-md bg-amber-500/20 px-2 text-xs font-medium text-amber-500 ring-1 ring-amber-500/50"
17
- >
18
- <ShieldOff className="h-3.5 w-3.5" />
19
- No approvals
20
- </button>
18
+ <Tooltip>
19
+ <TooltipTrigger asChild>
20
+ <button
21
+ type="button"
22
+ onClick={() => settingsUiActions.open("approvals")}
23
+ aria-label="No approvals mode is on. Open settings."
24
+ className="flex h-11 w-11 shrink-0 items-center justify-center gap-1 rounded-md bg-amber-500/20 text-xs font-medium text-amber-500 ring-1 ring-amber-500/50 md:h-8 md:w-8 @[22rem]:w-auto @[22rem]:px-2 [@media(hover:none)]:md:h-11"
25
+ >
26
+ <ShieldOff className="h-3.5 w-3.5" />
27
+ <span className="hidden @[22rem]:inline">No approvals</span>
28
+ </button>
29
+ </TooltipTrigger>
30
+ <TooltipContent>
31
+ No approvals mode is on: nothing waits for a person. Open settings.
32
+ </TooltipContent>
33
+ </Tooltip>
21
34
  );
22
35
  }
@@ -39,16 +39,16 @@ export function AwayToggle() {
39
39
  return (
40
40
  <ADropdownMenu
41
41
  minWidth="220px"
42
+ tooltip={
43
+ away
44
+ ? `Away ${until}`
45
+ : "Away: let the orchestrators follow work through"
46
+ }
42
47
  trigger={
43
48
  <Button
44
49
  variant="ghost"
45
50
  size="icon-sm"
46
51
  aria-label={away ? `Away ${until}` : "Away"}
47
- title={
48
- away
49
- ? `Away ${until}`
50
- : "Away: let the orchestrators follow work through"
51
- }
52
52
  disabled={setAway.isPending}
53
53
  className={cn(
54
54
  "relative h-11 w-11 md:h-8 md:w-8 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11",
@@ -56,7 +56,8 @@ import { McpServers } from "./McpServers";
56
56
  import { ChatSessionId } from "./session";
57
57
  import { ArtifactCard } from "./Artifact";
58
58
  import { PlanCard } from "./PlanCard";
59
- import { ChatIdle } from "./idle";
59
+ import { ChatIdle, RetryTurn } from "./idle";
60
+ import { RETRY_TEXT } from "@/lib/chat/turn-end";
60
61
  import { ContextMeter } from "./ContextMeter";
61
62
  import { SetupCard } from "./SetupCard";
62
63
  import { UserTurn } from "./EventRow";
@@ -631,6 +632,11 @@ export function ChatPanel({
631
632
  // Another conversation starts without the last one's find.
632
633
  useEffect(() => closeFind, [sessionId, closeFind]);
633
634
  const idle = !running && connected;
635
+ const lastEndId = items.findLast((i) => i.kind === "turn_end")?.id ?? null;
636
+ const retryTurn = useMemo(
637
+ () => ({ id: lastEndId, retry: () => send(RETRY_TEXT) }),
638
+ [lastEndId, send]
639
+ );
634
640
  const actions: ItemActions = useMemo(
635
641
  () => ({
636
642
  respond,
@@ -691,7 +697,9 @@ export function ChatPanel({
691
697
  </div>
692
698
  <ChatSessionId.Provider value={sessionId}>
693
699
  <ChatIdle.Provider value={idle}>
694
- <TimelineRows blocks={blocks} actions={actions} />
700
+ <RetryTurn.Provider value={retryTurn}>
701
+ <TimelineRows blocks={blocks} actions={actions} />
702
+ </RetryTurn.Provider>
695
703
  </ChatIdle.Provider>
696
704
  </ChatSessionId.Provider>
697
705
  {pending.map((p) => (
@@ -2,6 +2,7 @@ import { createElement } from "react";
2
2
  import { renderToStaticMarkup } from "react-dom/server";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import { AssistantMessage, TurnEnd, UserMessage } from "./Messages";
5
+ import { ChatIdle, RetryTurn } from "./idle";
5
6
 
6
7
  const item = {
7
8
  id: "a1",
@@ -131,3 +132,35 @@ describe("TurnEnd steps", () => {
131
132
  expect(html).toContain("Stopped · 4 of 5 steps done, 1 failed, 1 not run");
132
133
  });
133
134
  });
135
+
136
+ describe("TurnEnd retry", () => {
137
+ const end = (reason: string, id = "e") =>
138
+ ({
139
+ id,
140
+ kind: "turn_end",
141
+ ended: { reason, notice: "It stalled." },
142
+ }) as never;
143
+ const html = (item: never, retry: { id: string | null }, idle = true) =>
144
+ renderToStaticMarkup(
145
+ createElement(
146
+ ChatIdle.Provider,
147
+ { value: idle },
148
+ createElement(
149
+ RetryTurn.Provider,
150
+ { value: { ...retry, retry: () => {} } },
151
+ createElement(TurnEnd, { item })
152
+ )
153
+ )
154
+ );
155
+
156
+ it("offers Retry on the latest stalled end while idle", () => {
157
+ expect(html(end("stalled"), { id: "e" })).toContain(">Retry<");
158
+ expect(html(end("compaction_empty"), { id: "e" })).toContain(">Retry<");
159
+ });
160
+
161
+ it("does not on an older end, a running chat, or an ending retry can't fix", () => {
162
+ expect(html(end("stalled"), { id: "later" })).not.toContain("Retry");
163
+ expect(html(end("stalled"), { id: "e" }, false)).not.toContain("Retry");
164
+ expect(html(end("length"), { id: "e" })).not.toContain("Retry");
165
+ });
166
+ });
@@ -2,6 +2,8 @@
2
2
 
3
3
  import type { StepCounts } from "@/lib/chat/tool-outcome";
4
4
  import { memo } from "react";
5
+ import { retryableEnd } from "@/lib/chat/turn-end";
6
+ import { useChatIdle, useRetryTurn } from "./idle";
5
7
  import { useSnapshot } from "valtio";
6
8
  import {
7
9
  Check,
@@ -398,6 +400,10 @@ export function TurnEnd({
398
400
  }) {
399
401
  const secs = item.durationMs ? Math.round(item.durationMs / 1000) : null;
400
402
  const early = !item.interrupted && item.ended;
403
+ const idle = useChatIdle();
404
+ const retryTurn = useRetryTurn();
405
+ const canRetry =
406
+ !!early && idle && retryTurn.id === item.id && retryableEnd(early.reason);
401
407
  const label = item.interrupted
402
408
  ? "Stopped"
403
409
  : early
@@ -420,6 +426,15 @@ export function TurnEnd({
420
426
  finish reason: {item.finishReason}
421
427
  </span>
422
428
  )}
429
+ {canRetry && (
430
+ <button
431
+ type="button"
432
+ onClick={retryTurn.retry}
433
+ className="hover:bg-foreground/[0.06] mt-2 block min-h-11 rounded-md border border-current/30 px-3 text-sm font-medium md:min-h-8"
434
+ >
435
+ Retry
436
+ </button>
437
+ )}
423
438
  </div>
424
439
  )}
425
440
  <div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
@@ -7,3 +7,12 @@ import { createContext, useContext } from "react";
7
7
  // re-renders those buttons and not every row of a long conversation.
8
8
  export const ChatIdle = createContext(true);
9
9
  export const useChatIdle = () => useContext(ChatIdle);
10
+
11
+ // The latest turn's end and what retrying it does: only that end offers
12
+ // Retry, and only while the chat is idle. A context so the one turn ending
13
+ // doesn't re-render every row.
14
+ export const RetryTurn = createContext<{
15
+ id: string | null;
16
+ retry: () => void;
17
+ }>({ id: null, retry: () => {} });
18
+ export const useRetryTurn = () => useContext(RetryTurn);
@@ -32,7 +32,7 @@ export function MachineSwitcher() {
32
32
  type="button"
33
33
  aria-label={`Machine: ${current ? machineLabel(current) : "All machines"}`}
34
34
  className={cn(
35
- "border-border/70 text-muted-foreground hover:text-foreground hover:bg-foreground/[0.04] flex h-11 max-w-full min-w-0 items-center gap-1.5 rounded-lg border px-2.5 text-xs md:h-8",
35
+ "text-muted-foreground hover:text-foreground bg-card flex min-h-11 max-w-full min-w-0 items-center gap-1.5 rounded-xl border border-transparent px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
36
36
  current && "border-primary/40 bg-primary/10 text-primary"
37
37
  )}
38
38
  >
@@ -25,7 +25,7 @@ export function ProjectFilter({
25
25
  <button
26
26
  type="button"
27
27
  className={cn(
28
- "bg-card flex min-h-11 max-w-[7.5rem] shrink items-center gap-1 rounded-xl px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
28
+ "bg-card flex min-h-11 max-w-full min-w-0 shrink items-center gap-1 rounded-xl px-3 text-sm shadow-xs md:min-h-9 [@media(hover:none)]:md:min-h-11",
29
29
  current && "text-primary"
30
30
  )}
31
31
  >
@@ -67,8 +67,8 @@ export function SidebarHeader(props: SidebarHeaderProps) {
67
67
  const workspaceId = props.workspace?.id ?? null;
68
68
  const schedules = useSchedulesGlance(workspaceId);
69
69
  return (
70
- <div className="flex flex-wrap items-center gap-x-1 px-3 pt-3 pb-2">
71
- <div className="flex min-w-[9.5rem] flex-1 items-center">
70
+ <div className="@container flex flex-wrap items-center gap-x-1 gap-y-2 px-3 pt-3 pb-2">
71
+ <div className="flex min-w-[9.5rem] flex-1 basis-[9.5rem] items-center">
72
72
  <WorkspaceSwitcher
73
73
  workspaces={props.workspaces}
74
74
  current={props.workspace}
@@ -190,7 +190,7 @@ export function SidebarHeader(props: SidebarHeaderProps) {
190
190
  ]}
191
191
  />
192
192
  </div>
193
- <div className="w-full empty:hidden">
193
+ <div className="flex w-full min-w-0 empty:hidden">
194
194
  <MachineSwitcher />
195
195
  </div>
196
196
  </div>
@@ -7,7 +7,7 @@ import { sidebarUiActions } from "@/stores/sidebarUi";
7
7
  // sessions too.
8
8
  export function SidebarSearch({ query }: { query: string }) {
9
9
  return (
10
- <label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-[6rem] flex-1 items-center gap-2 rounded-xl px-3 shadow-xs focus-within:ring-2 md:min-h-9 [@media(hover:none)]:md:min-h-11">
10
+ <label className="bg-card text-muted-foreground focus-within:ring-primary/40 flex min-h-11 min-w-[6rem] flex-[1_1_8rem] items-center gap-2 rounded-xl px-3 shadow-xs focus-within:ring-2 md:min-h-9 [@media(hover:none)]:md:min-h-11">
11
11
  <Search className="h-3.5 w-3.5 shrink-0" />
12
12
  <input
13
13
  type="search"
@@ -168,7 +168,7 @@ export function SessionList({
168
168
  }
169
169
  pinControls={pinControls}
170
170
  />
171
- <div className="flex gap-2 px-3 pb-2">
171
+ <div className="flex flex-wrap gap-2 px-3 pb-2">
172
172
  <SidebarSearch query={ui.query} />
173
173
  <ProjectFilter
174
174
  projects={data.workspaceProjects}
@@ -38,6 +38,7 @@ vi.mock("@/data/approvals", () => ({
38
38
  }),
39
39
  }));
40
40
 
41
+ const { TooltipProvider } = await import("@/components/ui/tooltip");
41
42
  const { NoApprovalsSection } = await import("./NoApprovalsSection");
42
43
  const { NoApprovalsBadge } =
43
44
  await import("@/components/Approvals/NoApprovalsBadge");
@@ -112,10 +113,12 @@ describe("the No approvals toggle", () => {
112
113
 
113
114
  describe("the badge", () => {
114
115
  it("shows only while the mode is on", () => {
115
- render(createElement(NoApprovalsBadge));
116
+ const badge = () =>
117
+ createElement(TooltipProvider, null, createElement(NoApprovalsBadge));
118
+ render(badge());
116
119
  expect(host.textContent).toBe("");
117
120
  state.on = true;
118
- render(createElement(NoApprovalsBadge));
121
+ render(badge());
119
122
  expect(host.textContent).toContain("No approvals");
120
123
  });
121
124
  });
package/lib/chat/demo.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  ChatClientMessage,
12
12
  ChatServerMessage,
13
13
  ChatState,
14
+ Compacting,
14
15
  ModelWait,
15
16
  } from "./events";
16
17
  import { demoConversation } from "./drivers/demo";
@@ -31,7 +32,10 @@ const sends = new Map<string, number>();
31
32
 
32
33
  // What a demo chat is doing, for a watcher that connects mid-turn (the stored
33
34
  // items alone would show a running step as stopped).
34
- const live = new Map<string, { state: ChatState; wait: ModelWait | null }>();
35
+ const live = new Map<
36
+ string,
37
+ { state: ChatState; wait: ModelWait | null; compacting: Compacting | null }
38
+ >();
35
39
 
36
40
  /** The demo chat's running turn, or null with none. */
37
41
  export function demoLive(sessionId: string) {
@@ -46,6 +50,18 @@ function conversation(sessionId: string): ChatConversation {
46
50
  let tracker = new ModelWaitTracker();
47
51
  void (async () => {
48
52
  for await (const e of c.events) {
53
+ if (e.type === "compacting") {
54
+ // The tokens before are the scripted prompt's (drivers/demo-script).
55
+ const compacting = e.active
56
+ ? { since: Date.now(), before: 48_200 }
57
+ : null;
58
+ const state = live.get(sessionId);
59
+ if (state) state.compacting = compacting;
60
+ tracker.on({ type: "compacting", compacting } as WorkerEvent);
61
+ emit(sessionId, { type: "compacting", compacting });
62
+ emit(sessionId, { type: "model_wait", wait: tracker.wait });
63
+ continue;
64
+ }
49
65
  // Each turn follows its own waits, whatever the last one left.
50
66
  if (e.type === "state" && e.state === "running")
51
67
  tracker = new ModelWaitTracker();
@@ -53,7 +69,12 @@ function conversation(sessionId: string): ChatConversation {
53
69
  emit(sessionId, { type: "model_wait", wait: tracker.wait });
54
70
  if (e.type === "state") {
55
71
  if (e.state === "idle") live.delete(sessionId);
56
- else live.set(sessionId, { state: e.state, wait: tracker.wait });
72
+ else
73
+ live.set(sessionId, {
74
+ state: e.state,
75
+ wait: tracker.wait,
76
+ compacting: null,
77
+ });
57
78
  }
58
79
  const state = live.get(sessionId);
59
80
  if (state) state.wait = tracker.wait;
@@ -4,12 +4,17 @@
4
4
  * model, a retry) for a set time, then finishes. Demo only: the demo driver
5
5
  * is the one place that reads it (AGENTOS_DEMO=1; no real session gets here).
6
6
  *
7
- * demo: running turn [step|wait|retry] [hold=<seconds>]
7
+ * demo: running turn [step|wait|retry|compact|stalled|empty] [hold=<seconds>]
8
+ *
9
+ * compact holds a compaction; stalled and empty end the turn the way a stalled
10
+ * model request and an empty reply after compaction do (with a Retry).
8
11
  */
9
12
 
10
- import type { DriverEvent } from "../events";
13
+ import type { DriverEvent, TurnEndReason } from "../events";
14
+ import { COMPACTION_EMPTY_NOTICE, stalledNotice } from "./turn-outcome";
11
15
 
12
- export type DemoPhase = "step" | "wait" | "retry";
16
+ export type DemoPhase =
17
+ "step" | "wait" | "retry" | "compact" | "stalled" | "empty";
13
18
 
14
19
  export interface DemoScript {
15
20
  // Hold only this state; none runs all three, one after the other.
@@ -19,7 +24,8 @@ export interface DemoScript {
19
24
 
20
25
  export const DEFAULT_HOLD_S = 30;
21
26
  export const MAX_HOLD_S = 120;
22
- const TRIGGER = /^demo: running turn(?: (step|wait|retry))?(?: hold=(\d+))?$/;
27
+ const TRIGGER =
28
+ /^demo: running turn(?: (step|wait|retry|compact|stalled|empty))?(?: hold=(\d+))?$/;
23
29
 
24
30
  export function parseDemoScript(text: string): DemoScript | null {
25
31
  const m = TRIGGER.exec(text.trim());
@@ -99,6 +105,13 @@ async function runningStep(run: ScriptRun, id: string, holdMs: number) {
99
105
  });
100
106
  }
101
107
 
108
+ // A compaction of a big context, held, then done.
109
+ async function compacting(run: ScriptRun, holdMs: number) {
110
+ run.push({ type: "compacting", active: true });
111
+ await hold(run, holdMs);
112
+ run.push({ type: "compacting", active: false });
113
+ }
114
+
102
115
  export async function runDemoScript(
103
116
  script: DemoScript,
104
117
  run: ScriptRun,
@@ -108,6 +121,13 @@ export async function runDemoScript(
108
121
  const phases: DemoPhase[] = script.phase
109
122
  ? [script.phase]
110
123
  : ["step", "wait", "retry"];
124
+ // The end of a turn that stopped short, after one finished step.
125
+ const ending: { reason: TurnEndReason; notice: string } | undefined =
126
+ script.phase === "stalled"
127
+ ? { reason: "stalled", notice: stalledNotice(22 * 60_000, false) }
128
+ : script.phase === "empty"
129
+ ? { reason: "compaction_empty", notice: COMPACTION_EMPTY_NOTICE }
130
+ : undefined;
111
131
  run.push({ type: "state", state: "running" });
112
132
  run.push({ type: "turn_start" });
113
133
  run.push({
@@ -124,6 +144,11 @@ export async function runDemoScript(
124
144
  if (run.stopped()) break;
125
145
  if (phase === "step")
126
146
  await runningStep(run, `demo-t-${runId}`, script.holdMs);
147
+ else if (phase === "stalled" || phase === "empty") {
148
+ await runningStep(run, `demo-t-${runId}`, 0);
149
+ if (phase === "empty") await compacting(run, script.holdMs);
150
+ else await hold(run, script.holdMs);
151
+ } else if (phase === "compact") await compacting(run, script.holdMs);
127
152
  else {
128
153
  if (phase === "retry")
129
154
  run.push({
@@ -141,6 +166,7 @@ export async function runDemoScript(
141
166
  kind: "turn_end",
142
167
  durationMs: Date.now() - started,
143
168
  interrupted: run.stopped() || undefined,
169
+ ended: run.stopped() ? undefined : ending,
144
170
  createdAt: Date.now(),
145
171
  },
146
172
  });
@@ -13,6 +13,11 @@ type P = Record<string, unknown>;
13
13
  // that never answers still ends the request.
14
14
  export const HEADER_TIMEOUT_MS = 1_200_000;
15
15
 
16
+ // How long a turn may wait on the model with no sign of life before AgentOS
17
+ // stops it. Just past one header timeout: OpenCode would retry the same
18
+ // stall up to five more times, hours on a local model.
19
+ export const STALL_MS = HEADER_TIMEOUT_MS + 120_000;
20
+
16
21
  const isObject = (v: unknown): v is P =>
17
22
  !!v && typeof v === "object" && !Array.isArray(v);
18
23
 
@@ -2,7 +2,13 @@ import { randomUUID } from "crypto";
2
2
  import type { DriverEvent, UsageTotals } from "../events";
3
3
  import { openCodeTool } from "./opencode-rules";
4
4
  import { TurnItems } from "./turn-items";
5
- import { logTurnEnd, needsSummary, turnOutcome } from "./turn-outcome";
5
+ import {
6
+ logTurnEnd,
7
+ needsSummary,
8
+ stalledNotice,
9
+ turnOutcome,
10
+ type TurnEnded,
11
+ } from "./turn-outcome";
6
12
  import { retryNotice, TurnRetry, type RetryDecision } from "./transient-retry";
7
13
 
8
14
  type P = Record<string, unknown>;
@@ -12,6 +18,9 @@ const num = (v: unknown) => Number(v) || 0;
12
18
  // A prompt this big (tokens) is worth compacting before the last retry.
13
19
  const HEAVY_TOKENS = 16_000;
14
20
 
21
+ // Events that show the session is alive; a retry notice is not one.
22
+ const PROGRESS = /^(message\.|session\.(status|compacted|error)|todo\.)/;
23
+
15
24
  const TODO = new Set(["pending", "in_progress", "completed"]);
16
25
 
17
26
  // OpenCode's event stream as chat items, for one session (its subagents'
@@ -63,6 +72,15 @@ export class OpenCodeMapper {
63
72
  lastInputTokens = 0;
64
73
  // Compacting between retries makes the session busy and idle: not a turn.
65
74
  private holding = false;
75
+ // OpenCode is compacting the context (its summary request is out).
76
+ private compacting = false;
77
+ // A compaction ran in this turn; the messages that are its summary.
78
+ private compacted = false;
79
+ private summaries = new Set<string>();
80
+ // Tool calls not finished yet: the turn waits on them, not on the model.
81
+ private open = new Set<string>();
82
+ // When the session last did anything: the stall watchdog's clock.
83
+ private progressAt = Date.now();
66
84
 
67
85
  constructor(
68
86
  private sessionId: () => string | null,
@@ -84,12 +102,26 @@ export class OpenCodeMapper {
84
102
  this.agentRetried = false;
85
103
  }
86
104
  this.admitted = false;
105
+ this.progressAt = Date.now();
87
106
  this.items.startTurn();
88
107
  }
89
108
 
109
+ // How long the turn has waited on the model without a sign of life; 0
110
+ // while it isn't waiting on one (a tool runs, a retry is queued).
111
+ silentMs(now = Date.now()): number {
112
+ if (!this.items.inTurn || this.holding || this.retrying || this.open.size)
113
+ return 0;
114
+ return Math.max(0, now - this.progressAt);
115
+ }
116
+
90
117
  map(e: P): void {
91
118
  const p = (e.properties ?? {}) as P;
92
119
  if (p.sessionID && p.sessionID !== this.sessionId()) return;
120
+ if (
121
+ PROGRESS.test(e.type as string) &&
122
+ !(e.type === "session.status" && str((p.status as P)?.type) === "retry")
123
+ )
124
+ this.progressAt = Date.now();
93
125
  switch (e.type) {
94
126
  case "session.status": {
95
127
  const type = str((p.status as P)?.type);
@@ -113,6 +145,8 @@ export class OpenCodeMapper {
113
145
  case "session.error":
114
146
  return this.failed(p.error as P | undefined);
115
147
  case "session.compacted":
148
+ this.compacted = true;
149
+ this.setCompacting(false);
116
150
  return this.emit({
117
151
  type: "item",
118
152
  item: { id: randomUUID(), kind: "compacted", createdAt: Date.now() },
@@ -144,7 +178,15 @@ export class OpenCodeMapper {
144
178
  this.current.add(id);
145
179
  this.roles.set(id, str(info.role));
146
180
  if (info.role !== "assistant") return;
147
- if (this.last.id !== id)
181
+ const summary =
182
+ info.summary === true ||
183
+ info.mode === "compaction" ||
184
+ info.agent === "compaction";
185
+ if (summary) {
186
+ this.summaries.add(id);
187
+ this.setCompacting(!(info.time as P)?.completed && !info.error);
188
+ }
189
+ if (!summary && this.last.id !== id)
148
190
  this.last = {
149
191
  id,
150
192
  finish: "",
@@ -153,9 +195,11 @@ export class OpenCodeMapper {
153
195
  text: new Map(),
154
196
  calls: new Set(),
155
197
  };
156
- this.last.finish = str(info.finish) || this.last.finish;
157
- this.last.provider = str(info.providerID) || this.last.provider;
158
- this.last.model = str(info.modelID) || this.last.model;
198
+ if (!summary) {
199
+ this.last.finish = str(info.finish) || this.last.finish;
200
+ this.last.provider = str(info.providerID) || this.last.provider;
201
+ this.last.model = str(info.modelID) || this.last.model;
202
+ }
159
203
  if (info.error) this.failed(info.error as P);
160
204
  if (!(info.time as P)?.completed || this.counted.has(id)) return;
161
205
  this.counted.add(id);
@@ -181,8 +225,12 @@ export class OpenCodeMapper {
181
225
  const id = str(part.id);
182
226
  if (this.past.has(id) || this.past.has(str(part.messageID))) return;
183
227
  this.current.add(id);
228
+ // A compaction starts as a part of the message that asks for it.
229
+ if (part.type === "compaction") return this.setCompacting(true);
184
230
  // Only the agent's own messages: the prompt comes back as a part too.
185
231
  if (this.roles.get(str(part.messageID)) !== "assistant") return;
232
+ // The summary is the agent's memory, not part of the reply.
233
+ if (this.summaries.has(str(part.messageID))) return;
186
234
  if (part.type === "text" || part.type === "reasoning") {
187
235
  if (part.synthetic || part.ignored) return;
188
236
  const kind = part.type === "text" ? "assistant" : "reasoning";
@@ -200,6 +248,11 @@ export class OpenCodeMapper {
200
248
  }
201
249
  if (part.type === "tool") {
202
250
  this.toolIds.add(id);
251
+ const done = ["completed", "error"].includes(
252
+ str((part.state as P | undefined)?.status)
253
+ );
254
+ if (done) this.open.delete(id);
255
+ else this.open.add(id);
203
256
  if (this.last.id === str(part.messageID)) this.last.calls.add(id);
204
257
  this.tool(id, part);
205
258
  }
@@ -234,8 +287,15 @@ export class OpenCodeMapper {
234
287
  this.items.toolDone(id, "error", str(state.error), start);
235
288
  }
236
289
 
290
+ private setCompacting(on: boolean): void {
291
+ if (on === this.compacting) return;
292
+ this.compacting = on;
293
+ this.emit({ type: "compacting", active: on });
294
+ }
295
+
237
296
  private failed(error: P | undefined): void {
238
297
  if (!error) return;
298
+ this.setCompacting(false);
239
299
  if (error.name === "MessageAbortedError") this.aborted = true;
240
300
  else
241
301
  this.error =
@@ -268,6 +328,9 @@ export class OpenCodeMapper {
268
328
  for (const id of this.current) this.past.add(id);
269
329
  this.current.clear();
270
330
  this.parts.clear();
331
+ this.open.clear();
332
+ this.summaries.clear();
333
+ this.compacted = false;
271
334
  }
272
335
 
273
336
  // A failed turn is waiting for its retry to go out.
@@ -287,6 +350,7 @@ export class OpenCodeMapper {
287
350
 
288
351
  private idle(): void {
289
352
  if (this.holding || !this.admitted || !this.items.inTurn) return;
353
+ this.setCompacting(false);
290
354
  if (this.error && !this.aborted && this.retryTurn()) return;
291
355
  this.emit({ type: "usage", totals: { ...this.totals } });
292
356
  const text = [...this.last.text.values()].join("\n");
@@ -308,18 +372,26 @@ export class OpenCodeMapper {
308
372
  console.info("[chat] asked the model for its results summary");
309
373
  return;
310
374
  }
311
- const ended =
312
- this.aborted || !this.last.id
313
- ? undefined
314
- : turnOutcome({
315
- finishReason: this.last.finish,
316
- text,
317
- toolCalls: this.last.calls.size,
318
- error: this.error ?? undefined,
319
- retries: this.retry.tried,
320
- agentRetried: this.agentRetried,
321
- summary: this.items.summary(),
322
- });
375
+ const ended = this.aborted
376
+ ? undefined
377
+ : turnOutcome({
378
+ finishReason: this.last.finish,
379
+ text,
380
+ toolCalls: this.last.calls.size,
381
+ compacted: this.compacted,
382
+ toolsDone: /tool/i.test(this.last.finish) && !this.open.size,
383
+ error: this.error ?? undefined,
384
+ retries: this.retry.tried,
385
+ agentRetried: this.agentRetried,
386
+ summary: this.items.summary(),
387
+ });
388
+ this.finish(ended, text);
389
+ this.clearTurn();
390
+ this.nudged = this.nudgeDue = false;
391
+ }
392
+
393
+ // The turn is over: the end told, then the state reset.
394
+ private finish(ended: TurnEnded | undefined, text: string): void {
323
395
  if (ended)
324
396
  logTurnEnd({
325
397
  driver: "opencode",
@@ -335,6 +407,21 @@ export class OpenCodeMapper {
335
407
  finishReason: this.last.finish || undefined,
336
408
  ended,
337
409
  });
410
+ }
411
+
412
+ // The model stayed silent past the limit: the driver stops the session and
413
+ // the turn ends here, with what it got through kept.
414
+ stalled(ms: number): void {
415
+ if (!this.items.inTurn) return;
416
+ const compacting = this.compacting;
417
+ this.setCompacting(false);
418
+ this.retrying = false;
419
+ this.emit({ type: "usage", totals: { ...this.totals } });
420
+ const ended: TurnEnded = {
421
+ reason: "stalled",
422
+ notice: stalledNotice(ms, compacting),
423
+ };
424
+ this.finish(ended, "");
338
425
  this.clearTurn();
339
426
  this.nudged = this.nudgeDue = false;
340
427
  }
@@ -8,7 +8,7 @@ import { openCodeRules } from "./opencode-rules";
8
8
  import { OpenCodeServer } from "./opencode-server";
9
9
  import { OpenCodeApprovals } from "./opencode-approvals";
10
10
  import { RESULTS_BRIEF, SUMMARY_NUDGE } from "./turn-outcome";
11
- import { openCodeConfigContent } from "./opencode-config";
11
+ import { openCodeConfigContent, STALL_MS } from "./opencode-config";
12
12
  import { writeOpenCodeGuard } from "./opencode-plugin";
13
13
  import { typescriptPath } from "./edit-guard";
14
14
  import { continuePrompt, type RetryDecision } from "./transient-retry";
@@ -44,6 +44,9 @@ function conversationEnv(
44
44
  }
45
45
  }
46
46
 
47
+ // How often a waiting turn is checked for silence.
48
+ const WATCH_MS = 30_000;
49
+
47
50
  // How long a prompt may go without the session turning busy before the
48
51
  // turn is checked on.
49
52
  const ADMIT_MS = 15_000;
@@ -142,6 +145,20 @@ export const openCodeDriver: ChatDriver = {
142
145
  const held: { text: string; images?: ChatImage[] }[] = [];
143
146
  const stream = new AbortController();
144
147
  let closed = false;
148
+ // A turn silent for STALL_MS (an env override is for tests only) is
149
+ // stopped: OpenCode's own retries could wait for hours.
150
+ const stallMs = Number(process.env.AGENTOS_OPENCODE_STALL_MS) || STALL_MS;
151
+ const watchdog = setInterval(
152
+ () => {
153
+ const silent = mapper.silentMs();
154
+ if (silent < stallMs) return;
155
+ cancelRetry();
156
+ mapper.stalled(silent);
157
+ void abort();
158
+ },
159
+ Math.min(WATCH_MS, stallMs / 2)
160
+ );
161
+ watchdog.unref();
145
162
 
146
163
  const fail = (error: Error) => mapper.items.error(error.message);
147
164
  const ours = (id: unknown) => id === sessionId || children.has(str(id));
@@ -286,6 +303,7 @@ export const openCodeDriver: ChatDriver = {
286
303
  function close() {
287
304
  if (closed) return;
288
305
  closed = true;
306
+ clearInterval(watchdog);
289
307
  cancelRetry();
290
308
  approvals.pending.expireAll();
291
309
  stream.abort();
@@ -80,10 +80,26 @@ export function failureReport(o: {
80
80
  return `The model's server ${kind === "timeout" ? "timed out" : "kept failing"} (${why})${tries}. ${done} Not finished: the model's next reply, so the task is incomplete. Send "continue" to resume; the finished steps are kept.`;
81
81
  }
82
82
 
83
+ export const COMPACTION_EMPTY_NOTICE =
84
+ 'The model returned nothing after compaction, so the task is incomplete. Retry sends "continue"; the finished steps are kept.';
85
+
86
+ export const CUT_OFF_NOTICE =
87
+ 'The turn stopped right after a tool step, before the model replied, so the task may be incomplete. Retry sends "continue"; the finished steps are kept.';
88
+
89
+ // Said when a model request got no answer for the whole stall limit.
90
+ export function stalledNotice(ms: number, compacting: boolean): string {
91
+ const min = Math.max(1, Math.round(ms / 60_000));
92
+ return `${compacting ? "The model didn't finish compacting the context" : "Model request stalled"}: no response for ${plural(min, "minute")}, so AgentOS stopped waiting. The finished steps are kept. Retry sends "continue".`;
93
+ }
94
+
83
95
  export function turnOutcome(r: {
84
96
  finishReason?: string;
85
97
  text: string;
86
98
  toolCalls: number;
99
+ // A compaction ran in this turn.
100
+ compacted?: boolean;
101
+ // Every tool call of the turn has finished.
102
+ toolsDone?: boolean;
87
103
  error?: string;
88
104
  retries?: number;
89
105
  agentRetried?: boolean;
@@ -107,7 +123,12 @@ export function turnOutcome(r: {
107
123
  })
108
124
  : "The model's server returned an error, shown above.",
109
125
  };
110
- if (r.toolCalls > 0) return undefined;
126
+ if (r.toolCalls > 0)
127
+ return r.toolsDone
128
+ ? { reason: "cut_off", notice: CUT_OFF_NOTICE }
129
+ : undefined;
130
+ if (r.compacted && !r.text.trim())
131
+ return { reason: "compaction_empty", notice: COMPACTION_EMPTY_NOTICE };
111
132
  if (hasToolMarkup(r.text))
112
133
  return {
113
134
  reason: "parse_failure",
@@ -151,7 +151,16 @@ interface Base {
151
151
  }
152
152
 
153
153
  export type TurnEndReason =
154
- "no_tool_call" | "length" | "parse_failure" | "empty" | "error";
154
+ | "no_tool_call"
155
+ | "length"
156
+ | "parse_failure"
157
+ | "empty"
158
+ | "error"
159
+ // The model gave no reply for too long, or none after a compaction, or
160
+ // the turn stopped right after a tool step.
161
+ | "stalled"
162
+ | "compaction_empty"
163
+ | "cut_off";
155
164
 
156
165
  export type ChatItem =
157
166
  | (Base & {
package/lib/chat/group.ts CHANGED
@@ -1,8 +1,16 @@
1
- import type { ChatItem } from "./events";
1
+ import type { ChatItem, TurnEndReason } from "./events";
2
2
  import { readingOrder } from "./reading-order";
3
3
  import { stepCounts, type StepCounts } from "./tool-outcome";
4
4
  import { eventTurnAt, type EventTurn } from "./turns";
5
5
 
6
+ // Ends that stop a turn mid-task: they show how many steps got done.
7
+ const CUT_SHORT = new Set<TurnEndReason>([
8
+ "error",
9
+ "stalled",
10
+ "compaction_empty",
11
+ "cut_off",
12
+ ]);
13
+
6
14
  export type ToolItem = Extract<ChatItem, { kind: "tool" }>;
7
15
  export type UndoItem = Extract<ChatItem, { kind: "undo" }>;
8
16
 
@@ -99,7 +107,8 @@ export function groupTimeline(
99
107
  flush();
100
108
  const cut =
101
109
  item.kind === "turn_end" &&
102
- (item.interrupted || item.ended?.reason === "error");
110
+ (item.interrupted ||
111
+ (item.ended !== undefined && CUT_SHORT.has(item.ended.reason)));
103
112
  blocks.push(
104
113
  cut && turnTools.length
105
114
  ? { type: "item", item, steps: stepCounts(turnTools) }
@@ -7,6 +7,8 @@ export type Phase = "planning" | "coding" | "testing" | "error";
7
7
 
8
8
  // A wait on one model call this long gets a hint that it may be normal.
9
9
  export const LONG_WAIT_MS = 2 * 60_000;
10
+ export const LONG_COMPACT_HINT =
11
+ "Compacting a large context on a local model can take minutes.";
10
12
  export const LONG_WAIT_HINT =
11
13
  "Still waiting on the model; large prompts on local models can take minutes";
12
14
 
@@ -118,6 +120,8 @@ export function currentActivity(
118
120
  compacting: true,
119
121
  kind: "compacting",
120
122
  steps: 0,
123
+ hint:
124
+ now - compacting.since > LONG_WAIT_MS ? LONG_COMPACT_HINT : undefined,
121
125
  };
122
126
  const lastUser = items.findLast((i) => i.kind === "user" && !i.midTurn);
123
127
  const tools = turnTools(items);
@@ -44,7 +44,7 @@ export function watchChat(sessionId: string, listener: Listener): () => void {
44
44
  hasMore: page.hasMore,
45
45
  tasks: present(sessionId, tasks),
46
46
  state: live?.state ?? demo?.state ?? "idle",
47
- compacting: live?.compacting ?? null,
47
+ compacting: live?.compacting ?? demo?.compacting ?? null,
48
48
  modelWait: live?.modelWait ?? demo?.wait ?? null,
49
49
  queue: listQueue(sessionId),
50
50
  suggestion: getSession(sessionId).chat_suggestion ?? null,
@@ -0,0 +1,16 @@
1
+ import type { TurnEndReason } from "./events";
2
+
3
+ // The ends a Retry button can resume: the model's side failed or went quiet.
4
+ const RETRYABLE: TurnEndReason[] = [
5
+ "error",
6
+ "empty",
7
+ "stalled",
8
+ "compaction_empty",
9
+ "cut_off",
10
+ ];
11
+
12
+ export const retryableEnd = (reason: TurnEndReason) =>
13
+ RETRYABLE.includes(reason);
14
+
15
+ // What Retry sends: the steps that ran stay, the model carries on.
16
+ export const RETRY_TEXT = "continue";
@@ -163,6 +163,38 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
163
163
  };
164
164
  }
165
165
 
166
+ /**
167
+ * A cached answer was worked out against whatever HEAD was then. If the
168
+ * checkout has since moved (a pull by hand, a switch to another branch) and
169
+ * now contains the latest commit, there's nothing to update to.
170
+ */
171
+ export async function reconcile(
172
+ check: CheckResult | null,
173
+ deps: Pick<CheckDeps, "git">
174
+ ): Promise<CheckResult | null> {
175
+ if (!check?.available || !check.latest) return check;
176
+ try {
177
+ const head = (await deps.git(["rev-parse", "HEAD"])).trim();
178
+ if (head === check.current?.sha) return check;
179
+ const has = await deps
180
+ .git(["merge-base", "--is-ancestor", check.latest.sha, "HEAD"])
181
+ .then(
182
+ () => true,
183
+ () => false
184
+ );
185
+ return has
186
+ ? {
187
+ ...check,
188
+ available: false,
189
+ behind: 0,
190
+ current: { version: check.latest.version, sha: head },
191
+ }
192
+ : check;
193
+ } catch {
194
+ return check;
195
+ }
196
+ }
197
+
166
198
  let memo: CheckResult | null = null;
167
199
  let inflight: Promise<CheckResult> | null = null;
168
200
 
@@ -2,7 +2,7 @@
2
2
  // whether the caller may start an update.
3
3
 
4
4
  import { isActivePhase, type UpdateView } from "./banner";
5
- import { cachedCheck, checkForUpdate } from "./check";
5
+ import { cachedCheck, checkForUpdate, reconcile } from "./check";
6
6
  import {
7
7
  preflight,
8
8
  readLog,
@@ -40,6 +40,7 @@ export async function updateView(
40
40
  )
41
41
  check = (await checkForUpdate({ force: true, always: true }, deps.check))
42
42
  .result;
43
+ check = await reconcile(check, deps.check);
43
44
  // The log can carry paths and remote URLs; only a device that may update sees it.
44
45
  const run =
45
46
  stored && (stored.phase === "failed" || stored.phase === "built")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.744",
3
+ "version": "0.3.748",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"