@saadnvd1/agent-os 0.3.582 → 0.3.584
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/components/Chat/BackgroundTasks.tsx +58 -12
- package/components/views/DesktopAppActions.tsx +4 -1
- package/data/chat/useChat.ts +6 -3
- package/lib/chat/background.ts +54 -0
- package/lib/chat/events.ts +6 -1
- package/lib/chat/runner.ts +2 -1
- package/lib/chat/socket.ts +4 -1
- package/lib/chat/task-output.ts +8 -3
- package/lib/needs-you.ts +15 -1
- package/lib/recipes/settle.ts +28 -15
- package/lib/sidebar/shelves.ts +11 -0
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
X,
|
|
14
14
|
} from "lucide-react";
|
|
15
15
|
import type { ChatItem } from "@/lib/chat/events";
|
|
16
|
-
import {
|
|
16
|
+
import { outputLines, taskLife, type TaskOutput } from "@/lib/chat/background";
|
|
17
17
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
18
18
|
import { cn } from "@/lib/utils";
|
|
19
19
|
import { useNow } from "./useNow";
|
|
@@ -59,7 +59,7 @@ function TaskRow({
|
|
|
59
59
|
}: {
|
|
60
60
|
task: Task;
|
|
61
61
|
now: number;
|
|
62
|
-
output:
|
|
62
|
+
output: TaskOutput | undefined;
|
|
63
63
|
// Follows its output for the line under its name.
|
|
64
64
|
tail: boolean;
|
|
65
65
|
// Brought here from the call it came from.
|
|
@@ -77,7 +77,7 @@ function TaskRow({
|
|
|
77
77
|
useEffect(() => {
|
|
78
78
|
const el = outputRef.current;
|
|
79
79
|
if (el) el.scrollTop = el.scrollHeight;
|
|
80
|
-
}, [output]);
|
|
80
|
+
}, [output?.text]);
|
|
81
81
|
|
|
82
82
|
useEffect(() => {
|
|
83
83
|
if (!highlight) return;
|
|
@@ -95,8 +95,12 @@ function TaskRow({
|
|
|
95
95
|
}, [open, tail, running, taskId, onLoadOutput]);
|
|
96
96
|
|
|
97
97
|
const took = formatElapsed((task.endedAt ?? now) - task.createdAt);
|
|
98
|
-
const
|
|
99
|
-
|
|
98
|
+
const life = taskLife(task, output, now);
|
|
99
|
+
const text = output?.text;
|
|
100
|
+
const lines = outputLines(text);
|
|
101
|
+
const idle = formatElapsed(
|
|
102
|
+
Math.max(0, now - (output?.updatedAt ?? task.createdAt))
|
|
103
|
+
);
|
|
100
104
|
return (
|
|
101
105
|
<div
|
|
102
106
|
ref={rowRef}
|
|
@@ -137,10 +141,52 @@ function TaskRow({
|
|
|
137
141
|
</button>
|
|
138
142
|
)}
|
|
139
143
|
</div>
|
|
140
|
-
{
|
|
141
|
-
<
|
|
142
|
-
{
|
|
143
|
-
|
|
144
|
+
{!open && (
|
|
145
|
+
<div className="space-y-0.5 pr-2 pb-1.5 pl-9">
|
|
146
|
+
{life.state === "exited" && (
|
|
147
|
+
<p
|
|
148
|
+
className={cn(
|
|
149
|
+
"font-mono text-[11px]",
|
|
150
|
+
life.failed ? "text-destructive" : "text-muted-foreground"
|
|
151
|
+
)}
|
|
152
|
+
>
|
|
153
|
+
{life.code === null ? task.status : `exited ${life.code}`}
|
|
154
|
+
</p>
|
|
155
|
+
)}
|
|
156
|
+
{lines.length > 0 ? (
|
|
157
|
+
<pre className="text-muted-foreground max-h-[4.5rem] overflow-hidden font-mono text-[11px] leading-[1.15rem]">
|
|
158
|
+
{lines.map((l, i) => (
|
|
159
|
+
<span key={i} className="block truncate">
|
|
160
|
+
{l}
|
|
161
|
+
</span>
|
|
162
|
+
))}
|
|
163
|
+
</pre>
|
|
164
|
+
) : (
|
|
165
|
+
running && (
|
|
166
|
+
<p className="text-muted-foreground truncate font-mono text-[11px]">
|
|
167
|
+
{task.taskType && /monitor|watch/.test(task.taskType)
|
|
168
|
+
? "watching"
|
|
169
|
+
: "running"}
|
|
170
|
+
: {task.summary || task.description}
|
|
171
|
+
</p>
|
|
172
|
+
)
|
|
173
|
+
)}
|
|
174
|
+
{running && (
|
|
175
|
+
<p
|
|
176
|
+
className={cn(
|
|
177
|
+
"text-[11px] tabular-nums",
|
|
178
|
+
life.state === "quiet"
|
|
179
|
+
? "text-amber-600 dark:text-amber-400"
|
|
180
|
+
: "text-muted-foreground"
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{life.state === "quiet" ? "Quiet · " : ""}
|
|
184
|
+
{output?.updatedAt
|
|
185
|
+
? `last output ${idle} ago`
|
|
186
|
+
: `no output in ${idle}`}
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
144
190
|
)}
|
|
145
191
|
{open && (
|
|
146
192
|
<div className="space-y-1 pb-2 pl-7">
|
|
@@ -149,12 +195,12 @@ function TaskRow({
|
|
|
149
195
|
)}
|
|
150
196
|
{output === undefined ? (
|
|
151
197
|
<p className="text-muted-foreground text-xs">Loading output…</p>
|
|
152
|
-
) :
|
|
198
|
+
) : text ? (
|
|
153
199
|
<pre
|
|
154
200
|
ref={outputRef}
|
|
155
201
|
className="bg-foreground/[0.04] max-h-56 overflow-auto rounded-lg px-2 py-1.5 font-mono text-[11px] whitespace-pre-wrap"
|
|
156
202
|
>
|
|
157
|
-
{
|
|
203
|
+
{text}
|
|
158
204
|
</pre>
|
|
159
205
|
) : (
|
|
160
206
|
<p className="text-muted-foreground text-xs">
|
|
@@ -179,7 +225,7 @@ export function BackgroundTasks({
|
|
|
179
225
|
items: ChatItem[];
|
|
180
226
|
// A task to open the tray on, from the call that became it.
|
|
181
227
|
reveal?: { taskId: string; at: number } | null;
|
|
182
|
-
outputs: Record<string,
|
|
228
|
+
outputs: Record<string, TaskOutput>;
|
|
183
229
|
onStop: (taskId: string) => void;
|
|
184
230
|
onLoadOutput: (taskId: string) => void;
|
|
185
231
|
}) {
|
|
@@ -14,6 +14,8 @@ import { NotificationsMenu } from "@/components/NotificationsMenu";
|
|
|
14
14
|
import type { ViewProps } from "./types";
|
|
15
15
|
import { newDraft } from "@/stores/drafts";
|
|
16
16
|
import { paletteActions } from "@/stores/palette";
|
|
17
|
+
import { useClock } from "@/hooks/useMinuteTick";
|
|
18
|
+
import { inBell } from "@/lib/sidebar/shelves";
|
|
17
19
|
import { DRAFT_KEYS } from "@/lib/drafts";
|
|
18
20
|
|
|
19
21
|
// Labels collapse to icons when the pane bar carrying these is narrow
|
|
@@ -70,6 +72,7 @@ export function DesktopAppActions({
|
|
|
70
72
|
notificationSettings,
|
|
71
73
|
attachToSession,
|
|
72
74
|
}: AppActionsProps) {
|
|
75
|
+
const now = useClock();
|
|
73
76
|
return (
|
|
74
77
|
<div
|
|
75
78
|
className="flex shrink-0 items-center gap-0.5"
|
|
@@ -95,7 +98,7 @@ export function DesktopAppActions({
|
|
|
95
98
|
<NotificationsMenu
|
|
96
99
|
settings={notificationSettings}
|
|
97
100
|
waitingSessions={sessions
|
|
98
|
-
.filter((s) => sessionStatuses[s.id]
|
|
101
|
+
.filter((s) => inBell(s, sessionStatuses[s.id], now))
|
|
99
102
|
.map((s) => ({ id: s.id, name: s.name }))}
|
|
100
103
|
onSelectSession={(id) => {
|
|
101
104
|
const session = sessions.find((s) => s.id === id);
|
package/data/chat/useChat.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
} from "@/lib/chat/events";
|
|
23
23
|
import type { SearchResult } from "@/lib/chat/find";
|
|
24
24
|
import { addBody, touchBody } from "@/lib/chat/bodyCache";
|
|
25
|
-
import { backgroundFailure } from "@/lib/chat/background";
|
|
25
|
+
import { backgroundFailure, type TaskOutput } from "@/lib/chat/background";
|
|
26
26
|
|
|
27
27
|
// The index of the item with `id`, searched from the end: what changes is
|
|
28
28
|
// almost always the latest.
|
|
@@ -164,7 +164,7 @@ export function useChat(
|
|
|
164
164
|
const searchRequests = useRef(
|
|
165
165
|
new Map<string, (result: SearchResult) => void>()
|
|
166
166
|
);
|
|
167
|
-
const [taskOutputs, setTaskOutputs] = useState<Record<string,
|
|
167
|
+
const [taskOutputs, setTaskOutputs] = useState<Record<string, TaskOutput>>(
|
|
168
168
|
{}
|
|
169
169
|
);
|
|
170
170
|
const [canBackground, setCanBackground] = useState(false);
|
|
@@ -278,7 +278,10 @@ export function useChat(
|
|
|
278
278
|
);
|
|
279
279
|
if (message) toast(message, { duration: 2500 });
|
|
280
280
|
} else if (m.type === "task_output") {
|
|
281
|
-
setTaskOutputs((prev) => ({
|
|
281
|
+
setTaskOutputs((prev) => ({
|
|
282
|
+
...prev,
|
|
283
|
+
[m.taskId]: { text: m.text, updatedAt: m.updatedAt },
|
|
284
|
+
}));
|
|
282
285
|
} else if (m.type === "undo_preview") {
|
|
283
286
|
setUndoPreview({ from: m.from, preview: m.preview });
|
|
284
287
|
} else if (m.type === "undone") {
|
package/lib/chat/background.ts
CHANGED
|
@@ -75,3 +75,57 @@ export function outputTail(text: string | null | undefined): string | null {
|
|
|
75
75
|
}
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
// A running task that has printed nothing for this long is marked quiet.
|
|
80
|
+
export const QUIET_AFTER_MS = 5 * 60 * 1000;
|
|
81
|
+
const TAIL_LINES = 4;
|
|
82
|
+
|
|
83
|
+
// What the server reads of a task's output file: the end of it, and when it
|
|
84
|
+
// last changed.
|
|
85
|
+
export interface TaskOutput {
|
|
86
|
+
text: string | null;
|
|
87
|
+
updatedAt: number | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The last few lines that say anything, oldest first.
|
|
91
|
+
export function outputLines(
|
|
92
|
+
text: string | null | undefined,
|
|
93
|
+
max = TAIL_LINES
|
|
94
|
+
): string[] {
|
|
95
|
+
const lines = (text?.split("\n") ?? []).map((l) => l.trimEnd());
|
|
96
|
+
return lines.filter((l) => l.trim()).slice(-max);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// "... failed with exit code 65" from the agent's note on a finished task.
|
|
100
|
+
export function exitCode(summary: string | undefined): number | null {
|
|
101
|
+
const m =
|
|
102
|
+
summary &&
|
|
103
|
+
/exit(?:ed)?(?: with)?(?: status| code)?\s*:?\s*(-?\d+)/i.exec(summary);
|
|
104
|
+
return m ? Number(m[1]) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type TaskLife =
|
|
108
|
+
| { state: "active" }
|
|
109
|
+
| { state: "quiet"; idleMs: number }
|
|
110
|
+
| { state: "exited"; code: number | null; failed: boolean };
|
|
111
|
+
|
|
112
|
+
// Whether a task is progressing, has gone quiet, or has ended. Idle time
|
|
113
|
+
// counts from its last output, or from its start when it has printed nothing.
|
|
114
|
+
export function taskLife(
|
|
115
|
+
task: Pick<Task, "status" | "createdAt" | "summary">,
|
|
116
|
+
output: TaskOutput | null | undefined,
|
|
117
|
+
now: number
|
|
118
|
+
): TaskLife {
|
|
119
|
+
if (task.status !== "running") {
|
|
120
|
+
const code = exitCode(task.summary);
|
|
121
|
+
return {
|
|
122
|
+
state: "exited",
|
|
123
|
+
code: code ?? (task.status === "completed" ? 0 : null),
|
|
124
|
+
failed: task.status === "failed" || (code !== null && code !== 0),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const idleMs = Math.max(0, now - (output?.updatedAt ?? task.createdAt));
|
|
128
|
+
return idleMs >= QUIET_AFTER_MS
|
|
129
|
+
? { state: "quiet", idleMs }
|
|
130
|
+
: { state: "active" };
|
|
131
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -369,7 +369,12 @@ export type ChatServerMessage =
|
|
|
369
369
|
}
|
|
370
370
|
| { type: "context"; context: ChatContext | null }
|
|
371
371
|
| { type: "undo_preview"; from: string; preview: UndoPreview }
|
|
372
|
-
| {
|
|
372
|
+
| {
|
|
373
|
+
type: "task_output";
|
|
374
|
+
taskId: string;
|
|
375
|
+
text: string | null;
|
|
376
|
+
updatedAt: number | null;
|
|
377
|
+
}
|
|
373
378
|
| {
|
|
374
379
|
type: "background_result";
|
|
375
380
|
toolUseId?: string;
|
package/lib/chat/runner.ts
CHANGED
|
@@ -63,6 +63,7 @@ import { holdsQueue, settingUp } from "../sessions/setup-progress";
|
|
|
63
63
|
import { firstMessageId, launchPending } from "../tasks/launch-gate";
|
|
64
64
|
import type { ChatItem, FileSuggestion } from "./events";
|
|
65
65
|
import { taskOutputTail } from "./task-output";
|
|
66
|
+
import type { TaskOutput } from "./background";
|
|
66
67
|
import { cutOffSessions, cutOffTurn, RESUME_NOTE } from "./interrupted";
|
|
67
68
|
import { queueEvent } from "../orchestrator/events";
|
|
68
69
|
import { restoreActivity, track } from "./activity";
|
|
@@ -881,7 +882,7 @@ export async function backgroundChatTask(
|
|
|
881
882
|
export function chatTaskOutput(
|
|
882
883
|
sessionId: string,
|
|
883
884
|
taskId: string
|
|
884
|
-
):
|
|
885
|
+
): TaskOutput | null {
|
|
885
886
|
const task = itemsOfKind(sessionId, "task").find((i) => i.taskId === taskId);
|
|
886
887
|
if (task?.kind !== "task") return null;
|
|
887
888
|
return taskOutputTail(taskId, task.outputFile);
|
package/lib/chat/socket.ts
CHANGED
|
@@ -196,7 +196,10 @@ function onChatMessage(
|
|
|
196
196
|
reply({
|
|
197
197
|
type: "task_output",
|
|
198
198
|
taskId: msg.taskId,
|
|
199
|
-
|
|
199
|
+
...(chatTaskOutput(sessionId, msg.taskId) ?? {
|
|
200
|
+
text: null,
|
|
201
|
+
updatedAt: null,
|
|
202
|
+
}),
|
|
200
203
|
});
|
|
201
204
|
else if (msg.type === "history" && Number.isFinite(msg.before))
|
|
202
205
|
reply({
|
package/lib/chat/task-output.ts
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "fs";
|
|
|
2
2
|
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
4
|
|
|
5
|
+
import type { TaskOutput } from "./background";
|
|
6
|
+
|
|
5
7
|
const TAIL_BYTES = 6000;
|
|
6
8
|
const found = new Map<string, string>();
|
|
7
9
|
|
|
@@ -48,19 +50,22 @@ function walk(dir: string, name: string, depth: number): string | null {
|
|
|
48
50
|
export function taskOutputTail(
|
|
49
51
|
taskId: string,
|
|
50
52
|
knownFile?: string
|
|
51
|
-
):
|
|
53
|
+
): TaskOutput | null {
|
|
52
54
|
const file =
|
|
53
55
|
knownFile && fs.existsSync(knownFile) ? knownFile : findOutputFile(taskId);
|
|
54
56
|
if (!file) return null;
|
|
55
57
|
try {
|
|
56
|
-
const { size } = fs.statSync(file);
|
|
58
|
+
const { size, mtimeMs } = fs.statSync(file);
|
|
57
59
|
const fd = fs.openSync(file, "r");
|
|
58
60
|
try {
|
|
59
61
|
const start = Math.max(0, size - TAIL_BYTES);
|
|
60
62
|
const buf = Buffer.alloc(size - start);
|
|
61
63
|
fs.readSync(fd, buf, 0, buf.length, start);
|
|
62
64
|
const text = buf.toString("utf8");
|
|
63
|
-
return
|
|
65
|
+
return {
|
|
66
|
+
text: start > 0 ? text.slice(text.indexOf("\n") + 1) : text,
|
|
67
|
+
updatedAt: mtimeMs,
|
|
68
|
+
};
|
|
64
69
|
} finally {
|
|
65
70
|
fs.closeSync(fd);
|
|
66
71
|
}
|
package/lib/needs-you.ts
CHANGED
|
@@ -33,7 +33,12 @@ function latestChatItem(sessionId: string): ChatItem | null {
|
|
|
33
33
|
// is routine for it.
|
|
34
34
|
export function needsYou(
|
|
35
35
|
session: Pick<Session, "id" | "view" | "updated_at" | "last_seen_at"> &
|
|
36
|
-
Partial<
|
|
36
|
+
Partial<
|
|
37
|
+
Pick<
|
|
38
|
+
Session,
|
|
39
|
+
"role" | "workspace_id" | "task_status" | "settled_at" | "archived_at"
|
|
40
|
+
>
|
|
41
|
+
>,
|
|
37
42
|
chatState: ChatState | null
|
|
38
43
|
): boolean {
|
|
39
44
|
const seen = sqliteMs(session.last_seen_at);
|
|
@@ -43,7 +48,16 @@ export function needsYou(
|
|
|
43
48
|
if (chatState === "waiting") return true;
|
|
44
49
|
if (session.role === "orchestrator")
|
|
45
50
|
return openAskCount(session.workspace_id) > 0;
|
|
51
|
+
if (session.archived_at) return false;
|
|
46
52
|
const latest = latestChatItem(session.id);
|
|
53
|
+
// Put on the Done shelf after its last turn: that turn is history, not
|
|
54
|
+
// news. (settled_at has second precision.)
|
|
55
|
+
if (
|
|
56
|
+
latest &&
|
|
57
|
+
session.settled_at &&
|
|
58
|
+
latest.createdAt <= sqliteMs(session.settled_at) + 1000
|
|
59
|
+
)
|
|
60
|
+
return false;
|
|
47
61
|
return latest?.kind === "turn_end" && latest.createdAt > seen;
|
|
48
62
|
}
|
|
49
63
|
// A terminal session's updated_at moves when it starts waiting.
|
package/lib/recipes/settle.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { db, queries, type Session } from "../db";
|
|
|
7
7
|
import { finalMessage, usageRows } from "../session-logs/gather";
|
|
8
8
|
import { chatStateNow } from "../chat/runner";
|
|
9
9
|
import { doneSession } from "../done";
|
|
10
|
+
import { notifyStatusChanged } from "../status/hub";
|
|
10
11
|
import { blockedReason } from "../tasks/state";
|
|
11
12
|
import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
|
|
12
13
|
import { releasePieces } from "../plans/release";
|
|
@@ -406,23 +407,35 @@ async function settleOne(
|
|
|
406
407
|
} else idleSince.delete(run.id);
|
|
407
408
|
}
|
|
408
409
|
|
|
409
|
-
// After a run settles: a
|
|
410
|
-
//
|
|
411
|
-
//
|
|
410
|
+
// After a run settles: a session run that reported its result (ok or failed)
|
|
411
|
+
// is marked Done so the sidebar and the bell don't pile up; a task run is left
|
|
412
|
+
// to its gates. A run that ended without a result line, was blocked or timed
|
|
413
|
+
// out may be a real question, so it stays open. The settle event already told
|
|
414
|
+
// the orchestrator.
|
|
415
|
+
export function finishesSession(
|
|
416
|
+
row: { status: RunStatus; summary_source: string | null },
|
|
417
|
+
recipe: Pick<Recipe, "kind" | "auto_done">
|
|
418
|
+
): boolean {
|
|
419
|
+
if (recipe.kind !== "session" || !recipe.auto_done) return false;
|
|
420
|
+
if (row.status === "succeeded") return true;
|
|
421
|
+
return row.status === "failed" && row.summary_source === "line";
|
|
422
|
+
}
|
|
423
|
+
|
|
412
424
|
async function afterSettle(run: Live): Promise<void> {
|
|
413
425
|
const row = db
|
|
414
|
-
.prepare(`SELECT status FROM recipe_runs WHERE id = ?`)
|
|
415
|
-
.get(run.id) as
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
+
.prepare(`SELECT status, summary_source FROM recipe_runs WHERE id = ?`)
|
|
427
|
+
.get(run.id) as
|
|
428
|
+
| { status: RunStatus; summary_source: string | null }
|
|
429
|
+
| undefined;
|
|
430
|
+
if (!row || !run.session_id || !finishesSession(row, run.recipe)) return;
|
|
431
|
+
try {
|
|
432
|
+
await doneSession(run.session_id, { by: "direct", noMerge: true });
|
|
433
|
+
notifyStatusChanged();
|
|
434
|
+
} catch (error) {
|
|
435
|
+
console.warn(
|
|
436
|
+
`[recipes] couldn't mark run #${run.seq} (${run.session_id}) done:`,
|
|
437
|
+
error instanceof Error ? error.message : error
|
|
438
|
+
);
|
|
426
439
|
}
|
|
427
440
|
}
|
|
428
441
|
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -198,6 +198,17 @@ export function settledHow(
|
|
|
198
198
|
return now - active >= SETTLE_AFTER_MS ? "auto" : null;
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// Whether the bell lists a session: waiting on you, and not put away. Like
|
|
202
|
+
// the shelves, a live need wins over Done.
|
|
203
|
+
export function inBell(
|
|
204
|
+
session: Parameters<typeof settledHow>[0] & Pick<Session, "archived_at">,
|
|
205
|
+
status: RowStatus | undefined,
|
|
206
|
+
now: number
|
|
207
|
+
): boolean {
|
|
208
|
+
if (status?.status !== "waiting" || session.archived_at) return false;
|
|
209
|
+
return !!status.need || !settledHow(session, now);
|
|
210
|
+
}
|
|
211
|
+
|
|
201
212
|
// Woken by its timer and not opened since.
|
|
202
213
|
function wokeUnread(
|
|
203
214
|
session: Parameters<typeof activeMs>[0] & Pick<Session, "last_seen_at">,
|