@saadnvd1/agent-os 0.3.477 → 0.3.481
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 +26 -1
- package/app/api/schedules/route.ts +1 -0
- package/app/api/sessions/[id]/route.test.ts +66 -0
- package/app/api/sessions/[id]/route.ts +10 -7
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/components/Orchestrator/OrchestratorBar.tsx +12 -0
- package/components/Schedules/ScheduleForm.tsx +94 -12
- package/components/Schedules/SchedulesPanel.tsx +60 -14
- package/components/SessionList/OrchestratorPinRow.tsx +12 -6
- package/components/SessionList/QuickChangeRow.tsx +58 -0
- package/components/SessionList/SessionRow.tsx +4 -0
- package/components/SessionList/SidebarHeader.tsx +1 -1
- package/components/SessionList/useSchedulesGlance.ts +6 -2
- package/components/Tasks/TaskRow.tsx +2 -17
- package/components/Tasks/task-state-labels.ts +18 -0
- package/lib/away/sweep.ts +2 -0
- package/lib/chat/drivers/demo.ts +2 -1
- package/lib/chat/hold.ts +6 -2
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/test-touch.ts +58 -12
- package/lib/chat/worker/client.ts +2 -3
- package/lib/db/migrations.ts +31 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/index.ts +2 -8
- package/lib/orchestration.ts +4 -5
- package/lib/orchestrator/act.ts +3 -6
- package/lib/orchestrator/brief.ts +8 -3
- package/lib/orchestrator/conditions.ts +2 -1
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +13 -0
- package/lib/orchestrator/git-guard.ts +201 -0
- package/lib/orchestrator/pr-wake.ts +9 -1
- package/lib/orchestrator/quick-store.ts +18 -0
- package/lib/orchestrator/quick.ts +307 -0
- package/lib/orchestrator/rebase.ts +11 -1
- package/lib/orchestrator/reminders.ts +91 -0
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +26 -0
- package/lib/orchestrator/signoff.ts +28 -6
- package/lib/orchestrator/task-state.ts +2 -0
- package/lib/orchestrator/tool-names.ts +12 -2
- package/lib/orchestrator/tool-schemas.ts +43 -0
- package/lib/orchestrator/tools.ts +10 -0
- package/lib/orchestrator/write-guard.ts +80 -3
- package/lib/schedules/badge.ts +11 -1
- package/lib/schedules/cron.ts +1 -1
- package/lib/schedules/index.ts +67 -6
- package/lib/schedules/run.ts +23 -6
- package/lib/schedules/scheduler.ts +12 -2
- package/lib/schedules/start.ts +11 -5
- package/lib/schedules/store.ts +133 -16
- package/lib/schedules/when.ts +166 -0
- package/lib/sessions/end.ts +15 -0
- package/lib/sidebar/shelves.ts +19 -1
- package/lib/tasks/finish.ts +2 -8
- package/lib/tasks/index.ts +18 -6
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +2 -6
- package/lib/tasks/queue.ts +3 -2
- package/lib/tasks/start.ts +2 -4
- package/lib/tmux/end.ts +33 -0
- package/lib/tmux/orphans.ts +146 -0
- package/package.json +1 -1
- package/server.ts +9 -0
package/lib/schedules/start.ts
CHANGED
|
@@ -85,15 +85,21 @@ async function startSession(
|
|
|
85
85
|
// Marked as a schedule's, not something Saad just typed: the orchestrator's
|
|
86
86
|
// brief says it never counts as an approval.
|
|
87
87
|
export function scheduledMessage(
|
|
88
|
-
schedule: Pick<Schedule, "name" | "prompt"
|
|
88
|
+
schedule: Pick<Schedule, "name" | "prompt"> & { run_at?: number | null },
|
|
89
89
|
projectName: string | null,
|
|
90
90
|
// An agent set it up: it's that agent's request, not Saad's.
|
|
91
|
-
creator?: Pick<Session, "id" | "name"> | null
|
|
91
|
+
creator?: (Pick<Session, "id" | "name"> & { role?: string | null }) | null
|
|
92
92
|
): string {
|
|
93
93
|
const where = projectName ? ` for ${projectName}` : "";
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
const once = schedule.run_at != null;
|
|
95
|
+
const who =
|
|
96
|
+
creator?.role === "orchestrator" && once
|
|
97
|
+
? ", a reminder you set yourself with remind_me: your own follow-up, not an approval"
|
|
98
|
+
: creator
|
|
99
|
+
? `, set up by agent session "${creator.name}" (${creator.id.slice(0, 8)}), not by the user: a peer's request, not an approval`
|
|
100
|
+
: once
|
|
101
|
+
? ", a one-off reminder saved in Schedules: a prompt, not an approval"
|
|
102
|
+
: ", saved in Schedules: a standing prompt, not an approval";
|
|
97
103
|
return `[Scheduled message "${schedule.name}"${where}${who}]\n${schedule.prompt}`;
|
|
98
104
|
}
|
|
99
105
|
|
package/lib/schedules/store.ts
CHANGED
|
@@ -10,7 +10,14 @@ import {
|
|
|
10
10
|
MESSAGE_MIN_GAP_MINUTES,
|
|
11
11
|
MIN_GAP_MINUTES,
|
|
12
12
|
minGapMinutes,
|
|
13
|
+
formatRunTime,
|
|
13
14
|
} from "./cron";
|
|
15
|
+
import { parseWhen } from "./when";
|
|
16
|
+
|
|
17
|
+
// Agents can't aim a reminder at themselves sooner than this (a reminder
|
|
18
|
+
// loop), keep more than this many waiting, or set one past a year out.
|
|
19
|
+
export const MAX_AGENT_REMINDERS = 20;
|
|
20
|
+
const MAX_AHEAD_MS = 366 * 86_400_000;
|
|
14
21
|
|
|
15
22
|
export const SCHEDULE_KINDS = [
|
|
16
23
|
"task",
|
|
@@ -29,6 +36,7 @@ export interface Schedule {
|
|
|
29
36
|
workspace_id: string;
|
|
30
37
|
project_id: string | null;
|
|
31
38
|
name: string;
|
|
39
|
+
// '' for a one-off (the column is NOT NULL).
|
|
32
40
|
cron: string;
|
|
33
41
|
timezone: string;
|
|
34
42
|
prompt: string;
|
|
@@ -41,6 +49,10 @@ export interface Schedule {
|
|
|
41
49
|
enabled: boolean;
|
|
42
50
|
// Epoch ms: only slots after this run (set on create, enable, cron edit).
|
|
43
51
|
armed_at: number;
|
|
52
|
+
// A one-off's single run time (epoch ms, UTC), or null when recurring.
|
|
53
|
+
run_at: number | null;
|
|
54
|
+
// Set when a one-off's run settled; it's off from then on.
|
|
55
|
+
done_at: number | null;
|
|
44
56
|
archived_at: string | null;
|
|
45
57
|
created_at: string;
|
|
46
58
|
updated_at: string;
|
|
@@ -63,7 +75,10 @@ export interface ScheduleInput {
|
|
|
63
75
|
workspaceId: string;
|
|
64
76
|
projectId?: string | null;
|
|
65
77
|
name: string;
|
|
66
|
-
cron:
|
|
78
|
+
// A cron, or `at` for a one-off: exactly one.
|
|
79
|
+
cron?: string;
|
|
80
|
+
// A one-off's time: text parseWhen reads (in `timezone`), or epoch ms.
|
|
81
|
+
at?: string | number | null;
|
|
67
82
|
timezone?: string;
|
|
68
83
|
prompt: string;
|
|
69
84
|
kind: ScheduleKind;
|
|
@@ -93,6 +108,30 @@ export function listSchedules(workspaceId?: string | null): Schedule[] {
|
|
|
93
108
|
return rows.map(toSchedule);
|
|
94
109
|
}
|
|
95
110
|
|
|
111
|
+
export const isOneOff = (s: Pick<Schedule, "run_at">): boolean =>
|
|
112
|
+
s.run_at != null;
|
|
113
|
+
|
|
114
|
+
// One-offs still waiting to run, soonest first.
|
|
115
|
+
export function upcomingOneOffs(workspaceId?: string | null): Schedule[] {
|
|
116
|
+
return listSchedules(workspaceId)
|
|
117
|
+
.filter((s) => isOneOff(s) && s.enabled && !s.done_at)
|
|
118
|
+
.sort((a, b) => a.run_at! - b.run_at!);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Turns a one-off off once its run settled. True only for the call that did.
|
|
122
|
+
const byOrchestrator = (sessionId: string | null | undefined) =>
|
|
123
|
+
!!sessionId && getSessionRow(sessionId)?.role === "orchestrator";
|
|
124
|
+
|
|
125
|
+
export function finishOneOff(id: string, now = Date.now()): boolean {
|
|
126
|
+
return (
|
|
127
|
+
db
|
|
128
|
+
.prepare(
|
|
129
|
+
`UPDATE schedules SET enabled = 0, done_at = ?, updated_at = datetime('now') WHERE id = ? AND done_at IS NULL`
|
|
130
|
+
)
|
|
131
|
+
.run(now, id).changes === 1
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
96
135
|
export function getSchedule(id: string): Schedule | null {
|
|
97
136
|
const row = db.prepare(`SELECT * FROM schedules WHERE id = ?`).get(id) as
|
|
98
137
|
| Row
|
|
@@ -164,10 +203,18 @@ export function sessionTargetProblem(
|
|
|
164
203
|
return null;
|
|
165
204
|
}
|
|
166
205
|
|
|
206
|
+
type Valid = Required<
|
|
207
|
+
Omit<ScheduleInput, "createdBySessionId" | "cron" | "at">
|
|
208
|
+
> & { cron: string; runAt: number | null };
|
|
209
|
+
|
|
210
|
+
// `keepPast`: a run time already accepted, allowed to stay once it has passed
|
|
211
|
+
// (renaming a done one-off).
|
|
167
212
|
function validate(
|
|
168
213
|
input: ScheduleInput,
|
|
169
|
-
selfId?: string
|
|
170
|
-
)
|
|
214
|
+
selfId?: string,
|
|
215
|
+
now = Date.now(),
|
|
216
|
+
keepPast: number | null = null
|
|
217
|
+
): Valid {
|
|
171
218
|
const name = input.name?.trim();
|
|
172
219
|
if (!name) throw new Error("Give the schedule a name");
|
|
173
220
|
if (name.length > 80) throw new Error("Keep the name under 80 characters");
|
|
@@ -178,11 +225,50 @@ function validate(
|
|
|
178
225
|
if (!prompt) throw new Error("Write the prompt it runs");
|
|
179
226
|
if (!SCHEDULE_KINDS.includes(input.kind))
|
|
180
227
|
throw new Error("Pick task, session, orchestrator or message");
|
|
181
|
-
const cron = input.cron?.trim().replace(/\s+/g, " ");
|
|
182
|
-
const bad = cronError(cron ?? "");
|
|
183
|
-
if (bad) throw new Error(bad);
|
|
184
228
|
const timezone = input.timezone?.trim() || DEFAULT_TIMEZONE;
|
|
185
229
|
if (!isTimezone(timezone)) throw new Error(`Unknown time zone "${timezone}"`);
|
|
230
|
+
const cronText = input.cron?.trim().replace(/\s+/g, " ") ?? "";
|
|
231
|
+
const oneOff = input.at != null && input.at !== "";
|
|
232
|
+
if (oneOff && cronText)
|
|
233
|
+
throw new Error("Give a cron or a run time, not both");
|
|
234
|
+
if (!oneOff && !cronText) throw new Error("Needs a cron or a run time");
|
|
235
|
+
const runAt = oneOff
|
|
236
|
+
? typeof input.at === "number"
|
|
237
|
+
? input.at
|
|
238
|
+
: parseWhen(input.at as string, timezone, now)
|
|
239
|
+
: null;
|
|
240
|
+
if (runAt !== null) {
|
|
241
|
+
if (!Number.isFinite(runAt)) throw new Error("Pick a run time");
|
|
242
|
+
if (runAt <= now && runAt !== keepPast)
|
|
243
|
+
throw new Error(
|
|
244
|
+
`That time has passed (${formatRunTime(runAt, timezone)}): pick a time ahead`
|
|
245
|
+
);
|
|
246
|
+
if (runAt - now > MAX_AHEAD_MS)
|
|
247
|
+
throw new Error("A one-off can be at most a year ahead");
|
|
248
|
+
if (
|
|
249
|
+
input.createdBySessionId &&
|
|
250
|
+
runAt !== keepPast &&
|
|
251
|
+
runAt - now < MESSAGE_MIN_GAP_MINUTES * 60_000
|
|
252
|
+
)
|
|
253
|
+
throw new Error(
|
|
254
|
+
`A reminder must be at least ${MESSAGE_MIN_GAP_MINUTES} minutes ahead`
|
|
255
|
+
);
|
|
256
|
+
// The cap is on the orchestrator's remind_me reminders; a session's own
|
|
257
|
+
// `aos schedule add --at` isn't counted.
|
|
258
|
+
if (
|
|
259
|
+
!selfId &&
|
|
260
|
+
byOrchestrator(input.createdBySessionId) &&
|
|
261
|
+
upcomingOneOffs(input.workspaceId).filter((s) =>
|
|
262
|
+
byOrchestrator(s.created_by_session_id)
|
|
263
|
+
).length >= MAX_AGENT_REMINDERS
|
|
264
|
+
)
|
|
265
|
+
throw new Error(
|
|
266
|
+
`At most ${MAX_AGENT_REMINDERS} reminders can wait at once: cancel one first`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const cron = cronText;
|
|
270
|
+
const bad = oneOff ? null : cronError(cron);
|
|
271
|
+
if (bad) throw new Error(bad);
|
|
186
272
|
if (!getWorkspace(input.workspaceId)) throw new Error("Pick a workspace");
|
|
187
273
|
const message = input.kind === "message";
|
|
188
274
|
// A message goes to a session, whatever project it's in.
|
|
@@ -208,6 +294,7 @@ function validate(
|
|
|
208
294
|
throw new Error("A task or session schedule needs a project");
|
|
209
295
|
}
|
|
210
296
|
if (
|
|
297
|
+
!oneOff &&
|
|
211
298
|
(input.kind === "task" || input.kind === "session") &&
|
|
212
299
|
minGapMinutes(cron, timezone) < MIN_GAP_MINUTES
|
|
213
300
|
)
|
|
@@ -218,6 +305,7 @@ function validate(
|
|
|
218
305
|
const agentCheckIn =
|
|
219
306
|
input.kind === "orchestrator" && !!input.createdBySessionId;
|
|
220
307
|
if (
|
|
308
|
+
!oneOff &&
|
|
221
309
|
(message || agentCheckIn) &&
|
|
222
310
|
minGapMinutes(cron, timezone) < MESSAGE_MIN_GAP_MINUTES
|
|
223
311
|
)
|
|
@@ -225,7 +313,10 @@ function validate(
|
|
|
225
313
|
`A message schedule runs at most every ${MESSAGE_MIN_GAP_MINUTES} minutes`
|
|
226
314
|
);
|
|
227
315
|
const clash = listSchedules(input.workspaceId).find(
|
|
228
|
-
(s) =>
|
|
316
|
+
(s) =>
|
|
317
|
+
s.id !== selfId &&
|
|
318
|
+
!s.done_at &&
|
|
319
|
+
s.name.toLowerCase() === name.toLowerCase()
|
|
229
320
|
);
|
|
230
321
|
if (clash)
|
|
231
322
|
throw new Error(`There's already a schedule called "${clash.name}"`);
|
|
@@ -239,6 +330,7 @@ function validate(
|
|
|
239
330
|
kind: input.kind,
|
|
240
331
|
targetSessionId,
|
|
241
332
|
enabled: input.enabled ?? true,
|
|
333
|
+
runAt,
|
|
242
334
|
};
|
|
243
335
|
}
|
|
244
336
|
|
|
@@ -246,11 +338,11 @@ export function createSchedule(
|
|
|
246
338
|
input: ScheduleInput,
|
|
247
339
|
now = Date.now()
|
|
248
340
|
): Schedule {
|
|
249
|
-
const v = validate(input);
|
|
341
|
+
const v = validate(input, undefined, now);
|
|
250
342
|
const id = randomUUID();
|
|
251
343
|
db.prepare(
|
|
252
|
-
`INSERT INTO schedules (id, workspace_id, project_id, name, cron, timezone, prompt, kind, target_session_id, created_by_session_id, enabled, armed_at)
|
|
253
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
344
|
+
`INSERT INTO schedules (id, workspace_id, project_id, name, cron, timezone, prompt, kind, target_session_id, created_by_session_id, enabled, armed_at, run_at)
|
|
345
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
254
346
|
).run(
|
|
255
347
|
id,
|
|
256
348
|
v.workspaceId,
|
|
@@ -263,7 +355,8 @@ export function createSchedule(
|
|
|
263
355
|
v.targetSessionId,
|
|
264
356
|
input.createdBySessionId ?? null,
|
|
265
357
|
v.enabled ? 1 : 0,
|
|
266
|
-
now
|
|
358
|
+
now,
|
|
359
|
+
v.runAt
|
|
267
360
|
);
|
|
268
361
|
return getSchedule(id)!;
|
|
269
362
|
}
|
|
@@ -281,7 +374,11 @@ export function updateSchedule(
|
|
|
281
374
|
projectId:
|
|
282
375
|
patch.projectId === undefined ? current.project_id : patch.projectId,
|
|
283
376
|
name: patch.name ?? current.name,
|
|
284
|
-
|
|
377
|
+
// A patch with a cron makes it recurring; one with `at`, a one-off.
|
|
378
|
+
cron:
|
|
379
|
+
patch.cron ??
|
|
380
|
+
(patch.at != null ? undefined : current.cron || undefined),
|
|
381
|
+
at: patch.at ?? (patch.cron != null ? null : current.run_at),
|
|
285
382
|
timezone: patch.timezone ?? current.timezone,
|
|
286
383
|
prompt: patch.prompt ?? current.prompt,
|
|
287
384
|
kind: patch.kind ?? current.kind,
|
|
@@ -292,17 +389,27 @@ export function updateSchedule(
|
|
|
292
389
|
enabled: patch.enabled ?? current.enabled,
|
|
293
390
|
createdBySessionId: current.created_by_session_id,
|
|
294
391
|
},
|
|
295
|
-
id
|
|
392
|
+
id,
|
|
393
|
+
now,
|
|
394
|
+
current.done_at || current.enabled ? current.run_at : null
|
|
296
395
|
);
|
|
396
|
+
// A one-off moved to a new time opens again, even if it had run.
|
|
397
|
+
const reopen = v.runAt !== null && v.runAt !== current.run_at;
|
|
398
|
+
const enabled = reopen
|
|
399
|
+
? (patch.enabled ?? true)
|
|
400
|
+
: v.runAt !== null && current.done_at
|
|
401
|
+
? false
|
|
402
|
+
: v.enabled;
|
|
297
403
|
// A new time or a fresh enable starts counting from now: no slot from
|
|
298
404
|
// before it is "missed".
|
|
299
405
|
const rearm =
|
|
300
406
|
v.cron !== current.cron ||
|
|
407
|
+
v.runAt !== current.run_at ||
|
|
301
408
|
v.timezone !== current.timezone ||
|
|
302
|
-
(
|
|
409
|
+
(enabled && !current.enabled);
|
|
303
410
|
db.prepare(
|
|
304
411
|
`UPDATE schedules SET workspace_id = ?, project_id = ?, name = ?, cron = ?, timezone = ?,
|
|
305
|
-
prompt = ?, kind = ?, target_session_id = ?, enabled = ?, armed_at = ?, updated_at = datetime('now')
|
|
412
|
+
prompt = ?, kind = ?, target_session_id = ?, enabled = ?, armed_at = ?, run_at = ?, done_at = ?, updated_at = datetime('now')
|
|
306
413
|
WHERE id = ?`
|
|
307
414
|
).run(
|
|
308
415
|
v.workspaceId,
|
|
@@ -313,8 +420,10 @@ export function updateSchedule(
|
|
|
313
420
|
v.prompt,
|
|
314
421
|
v.kind,
|
|
315
422
|
v.targetSessionId,
|
|
316
|
-
|
|
423
|
+
enabled ? 1 : 0,
|
|
317
424
|
rearm ? now : current.armed_at,
|
|
425
|
+
v.runAt,
|
|
426
|
+
reopen || v.runAt === null ? null : current.done_at,
|
|
318
427
|
id
|
|
319
428
|
);
|
|
320
429
|
return getSchedule(id)!;
|
|
@@ -546,6 +655,14 @@ export function failAbandonedClaims(
|
|
|
546
655
|
now = Date.now()
|
|
547
656
|
): number {
|
|
548
657
|
const before = Math.floor((now - olderThanMs) / 1000);
|
|
658
|
+
// A one-off whose only run was cut off won't come round again: finish it
|
|
659
|
+
// here so it leaves Upcoming and stops counting toward the reminder cap.
|
|
660
|
+
db.prepare(
|
|
661
|
+
`UPDATE schedules SET enabled = 0, done_at = ?, updated_at = datetime('now')
|
|
662
|
+
WHERE run_at IS NOT NULL AND done_at IS NULL AND id IN (
|
|
663
|
+
SELECT schedule_id FROM schedule_runs
|
|
664
|
+
WHERE outcome = 'claimed' AND created_at < datetime(?, 'unixepoch'))`
|
|
665
|
+
).run(now, before);
|
|
549
666
|
const started = db
|
|
550
667
|
.prepare(
|
|
551
668
|
`UPDATE schedule_runs SET outcome = 'started', detail = 'AgentOS restarted while it was starting'
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// One parser for "when" so the API, the CLI and the orchestrator's remind_me
|
|
2
|
+
// all read a time the same way: an ISO timestamp, "tomorrow 10am", "in 2h".
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_TIMEZONE, wall, wallToInstant } from "./cron";
|
|
5
|
+
|
|
6
|
+
const DAYS = [
|
|
7
|
+
"sunday",
|
|
8
|
+
"monday",
|
|
9
|
+
"tuesday",
|
|
10
|
+
"wednesday",
|
|
11
|
+
"thursday",
|
|
12
|
+
"friday",
|
|
13
|
+
"saturday",
|
|
14
|
+
];
|
|
15
|
+
const dayIndex = (word: string): number =>
|
|
16
|
+
DAYS.findIndex((d) => d === word || d.slice(0, 3) === word);
|
|
17
|
+
|
|
18
|
+
const bad = (text: string) =>
|
|
19
|
+
new Error(
|
|
20
|
+
`Can't read "${text}" as a time: try "tomorrow 10am", "in 2h" or 2026-10-12T10:00`
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const UNIT_MS: Record<string, number> = {
|
|
24
|
+
m: 60_000,
|
|
25
|
+
h: 3_600_000,
|
|
26
|
+
d: 86_400_000,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function duration(text: string): number | null {
|
|
30
|
+
let total = 0;
|
|
31
|
+
const rest = text.replace(
|
|
32
|
+
/(\d+(?:\.\d+)?|an?)\s*(minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/g,
|
|
33
|
+
(_, n: string, unit: string) => {
|
|
34
|
+
total += (/^an?$/.test(n) ? 1 : Number(n)) * UNIT_MS[unit[0]];
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
return total > 0 && /^(\s|,|and)*$/.test(rest) ? total : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clock(text: string): { h: number; mi: number } | null {
|
|
42
|
+
if (text === "noon") return { h: 12, mi: 0 };
|
|
43
|
+
if (text === "midnight") return { h: 0, mi: 0 };
|
|
44
|
+
const m = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/.exec(text);
|
|
45
|
+
if (!m) return null;
|
|
46
|
+
const mi = m[2] ? Number(m[2]) : 0;
|
|
47
|
+
let h = Number(m[1]);
|
|
48
|
+
if (mi > 59) return null;
|
|
49
|
+
if (m[3]) {
|
|
50
|
+
if (h < 1 || h > 12) return null;
|
|
51
|
+
h = (h % 12) + (m[3] === "pm" ? 12 : 0);
|
|
52
|
+
} else if (!m[2] || h > 23) {
|
|
53
|
+
// "tomorrow 10" is ambiguous; 24-hour times need their minutes.
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return { h, mi };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CLOCK = String.raw`noon|midnight|\d{1,2}(?::\d{2})?\s*(?:am|pm)?`;
|
|
60
|
+
const DAY = String.raw`(?:next\s+)?(?:today|tomorrow|tmrw|[a-z]{3,9})`;
|
|
61
|
+
const DAY_FIRST = new RegExp(`^(${DAY})\\s+(?:at\\s+)?(${CLOCK})$`);
|
|
62
|
+
const CLOCK_FIRST = new RegExp(`^(?:at\\s+)?(${CLOCK})(?:\\s+(${DAY}))?$`);
|
|
63
|
+
|
|
64
|
+
function onDay(
|
|
65
|
+
word: string | undefined,
|
|
66
|
+
at: { h: number; mi: number },
|
|
67
|
+
now: number,
|
|
68
|
+
tz: string
|
|
69
|
+
): number | null {
|
|
70
|
+
const today = wall(now, tz);
|
|
71
|
+
const instantIn = (days: number) => {
|
|
72
|
+
const d = new Date(Date.UTC(today.y, today.m - 1, today.d + days));
|
|
73
|
+
return wallToInstant(
|
|
74
|
+
d.getUTCFullYear(),
|
|
75
|
+
d.getUTCMonth() + 1,
|
|
76
|
+
d.getUTCDate(),
|
|
77
|
+
at.h,
|
|
78
|
+
at.mi,
|
|
79
|
+
tz
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
const w = word?.replace(/^next\s+/, "");
|
|
83
|
+
if (!w) return instantIn(0) > now ? instantIn(0) : instantIn(1);
|
|
84
|
+
if (w === "today") return instantIn(0);
|
|
85
|
+
if (w === "tomorrow" || w === "tmrw") return instantIn(1);
|
|
86
|
+
const dow = dayIndex(w);
|
|
87
|
+
if (dow < 0) return null;
|
|
88
|
+
const todayDow = new Date(
|
|
89
|
+
Date.UTC(today.y, today.m - 1, today.d)
|
|
90
|
+
).getUTCDay();
|
|
91
|
+
const delta = (dow - todayDow + 7) % 7;
|
|
92
|
+
return delta === 0 && instantIn(0) <= now ? instantIn(7) : instantIn(delta);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The instant (epoch ms) the text names. An offset or Z is absolute; any other
|
|
96
|
+
// wall time reads in `tz`; "in 2h" is an exact duration from `now`.
|
|
97
|
+
export function parseWhen(
|
|
98
|
+
text: string,
|
|
99
|
+
tz = DEFAULT_TIMEZONE,
|
|
100
|
+
now = Date.now()
|
|
101
|
+
): number {
|
|
102
|
+
const t = text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
103
|
+
if (!t) throw bad(text);
|
|
104
|
+
|
|
105
|
+
const abs =
|
|
106
|
+
/^(\d{4}-\d{2}-\d{2})[t ](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)(z|[+-]\d{2}:?\d{2})$/.exec(
|
|
107
|
+
t
|
|
108
|
+
);
|
|
109
|
+
if (abs) {
|
|
110
|
+
const zone = abs[3] === "z" ? "Z" : abs[3].replace(/^([+-]\d{2}):?/, "$1:");
|
|
111
|
+
const ms = Date.parse(`${abs[1]}T${abs[2]}${zone}`);
|
|
112
|
+
if (Number.isNaN(ms)) throw bad(text);
|
|
113
|
+
return ms;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const iso =
|
|
117
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:[t ](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?)?$/.exec(
|
|
118
|
+
t
|
|
119
|
+
);
|
|
120
|
+
if (iso) {
|
|
121
|
+
const [y, m, d] = [Number(iso[1]), Number(iso[2]), Number(iso[3])];
|
|
122
|
+
const h = iso[4] === undefined ? 9 : Number(iso[4]);
|
|
123
|
+
const mi = iso[5] === undefined ? 0 : Number(iso[5]);
|
|
124
|
+
const check = new Date(Date.UTC(y, m - 1, d));
|
|
125
|
+
if (
|
|
126
|
+
check.getUTCMonth() !== m - 1 ||
|
|
127
|
+
check.getUTCDate() !== d ||
|
|
128
|
+
h > 23 ||
|
|
129
|
+
mi > 59
|
|
130
|
+
)
|
|
131
|
+
throw bad(text);
|
|
132
|
+
return wallToInstant(y, m, d, h, mi, tz);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const inside = /^in (.+)$/.exec(t);
|
|
136
|
+
if (inside) {
|
|
137
|
+
const ms = duration(inside[1]);
|
|
138
|
+
if (ms === null) throw bad(text);
|
|
139
|
+
return now + ms;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const first = DAY_FIRST.exec(t);
|
|
143
|
+
const second = first ? null : CLOCK_FIRST.exec(t);
|
|
144
|
+
const at = clock(first?.[2] ?? second?.[1] ?? "");
|
|
145
|
+
if (at) {
|
|
146
|
+
const instant = onDay(first?.[1] ?? second?.[2], at, now, tz);
|
|
147
|
+
if (instant !== null) return instant;
|
|
148
|
+
}
|
|
149
|
+
throw bad(text);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// "in 17h", "in 5m", "3h ago".
|
|
153
|
+
export function relativeTime(ms: number, now = Date.now()): string {
|
|
154
|
+
const diff = ms - now;
|
|
155
|
+
const mins = Math.floor(Math.abs(diff) / 60_000);
|
|
156
|
+
const span =
|
|
157
|
+
mins < 1
|
|
158
|
+
? null
|
|
159
|
+
: mins < 60
|
|
160
|
+
? `${mins}m`
|
|
161
|
+
: mins < 24 * 60
|
|
162
|
+
? `${Math.floor(mins / 60)}h`
|
|
163
|
+
: `${Math.floor(mins / (24 * 60))}d`;
|
|
164
|
+
if (!span) return diff >= 0 ? "in under a minute" : "just now";
|
|
165
|
+
return diff >= 0 ? `in ${span}` : `${span} ago`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Session } from "../db";
|
|
2
|
+
import { stopChat } from "../chat/runner";
|
|
3
|
+
import { waitForExit } from "../chat/worker/client";
|
|
4
|
+
import { endTmuxSession } from "../tmux/end";
|
|
5
|
+
|
|
6
|
+
type Agent = Pick<Session, "id" | "host_id" | "tmux_name">;
|
|
7
|
+
|
|
8
|
+
// A session's agent, ended: its chat worker, and its own tmux session (a
|
|
9
|
+
// chat session can still own one). The one call that delete, done, drop and
|
|
10
|
+
// finish share.
|
|
11
|
+
export async function endSessionAgent(s: Agent): Promise<void> {
|
|
12
|
+
stopChat(s.id);
|
|
13
|
+
void waitForExit(s.id).catch(() => {});
|
|
14
|
+
await endTmuxSession(s.host_id, s.tmux_name);
|
|
15
|
+
}
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -41,6 +41,10 @@ export interface SidebarRow {
|
|
|
41
41
|
working: boolean;
|
|
42
42
|
// Orchestration workers, nested under their conductor.
|
|
43
43
|
workers: SidebarRow[];
|
|
44
|
+
// Its task's state, when it's a task.
|
|
45
|
+
task?: TaskState;
|
|
46
|
+
// An orchestrator's quick changes (orchestrator/quick.ts), nested under it.
|
|
47
|
+
quick?: SidebarRow[];
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
export interface Shelves {
|
|
@@ -113,6 +117,8 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
|
|
|
113
117
|
unread: !!status?.unread,
|
|
114
118
|
working: status?.status === "running",
|
|
115
119
|
workers: [],
|
|
120
|
+
task: input.tasks[session.id],
|
|
121
|
+
quick: [],
|
|
116
122
|
};
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -129,6 +135,15 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
129
135
|
|
|
130
136
|
const top: SidebarRow[] = [];
|
|
131
137
|
for (const row of rows) {
|
|
138
|
+
// A quick change always sits under the orchestrator that makes it: it's
|
|
139
|
+
// that orchestrator's to carry, never one that needs you.
|
|
140
|
+
const owner = row.session.quick_of
|
|
141
|
+
? byId.get(row.session.quick_of)
|
|
142
|
+
: undefined;
|
|
143
|
+
if (owner) {
|
|
144
|
+
(owner.quick ??= []).push(row);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
132
147
|
const conductor = row.session.conductor_session_id
|
|
133
148
|
? byId.get(row.session.conductor_session_id)
|
|
134
149
|
: undefined;
|
|
@@ -200,8 +215,11 @@ export function sameRow(a: SidebarRow, b: SidebarRow): boolean {
|
|
|
200
215
|
a.unread === b.unread &&
|
|
201
216
|
a.working === b.working &&
|
|
202
217
|
sameStatus(a.status, b.status) &&
|
|
218
|
+
a.task === b.task &&
|
|
203
219
|
a.workers.length === b.workers.length &&
|
|
204
|
-
a.workers.every((w, i) => sameRow(w, b.workers[i]))
|
|
220
|
+
a.workers.every((w, i) => sameRow(w, b.workers[i])) &&
|
|
221
|
+
(a.quick?.length ?? 0) === (b.quick?.length ?? 0) &&
|
|
222
|
+
(a.quick ?? []).every((q, i) => sameRow(q, b.quick![i]))
|
|
205
223
|
);
|
|
206
224
|
}
|
|
207
225
|
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { republishAfterMerge } from "../lumifyhub/publish";
|
|
|
10
10
|
import { restackAfterMerge } from "../stacks/restack";
|
|
11
11
|
import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
|
|
12
12
|
import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
|
|
13
|
-
import {
|
|
13
|
+
import { endSessionAgent } from "../sessions/end";
|
|
14
14
|
import { run } from "./gh";
|
|
15
15
|
import { gitFetch, gitPush } from "../git-exec";
|
|
16
16
|
import {
|
|
@@ -39,13 +39,7 @@ async function cleanup(
|
|
|
39
39
|
opts: { keepRemoteBranch?: boolean; merged?: MergedAs } = {}
|
|
40
40
|
): Promise<void> {
|
|
41
41
|
const keepRemoteBranch = opts.keepRemoteBranch ?? false;
|
|
42
|
-
|
|
43
|
-
if (session.view === "chat") stopChat(session.id);
|
|
44
|
-
await run(
|
|
45
|
-
"tmux",
|
|
46
|
-
["kill-session", "-t", `=${session.tmux_name}`],
|
|
47
|
-
repo
|
|
48
|
-
).catch(() => {});
|
|
42
|
+
await endSessionAgent(session);
|
|
49
43
|
// Never waited on: the row is read now, and Postgres can't hold this up.
|
|
50
44
|
void dropSessionDatabase(session.id);
|
|
51
45
|
if (session.worktree_path && opts.merged) {
|
package/lib/tasks/index.ts
CHANGED
|
@@ -96,6 +96,8 @@ export interface TaskView {
|
|
|
96
96
|
// plan step (lib/models).
|
|
97
97
|
model?: string;
|
|
98
98
|
planning?: boolean;
|
|
99
|
+
// A quick change: the orchestrator session that makes it (no agent).
|
|
100
|
+
quickOf?: string | null;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
export async function createTask(opts: {
|
|
@@ -304,22 +306,31 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
304
306
|
const setup = taskSetupOf(session);
|
|
305
307
|
// A finished task's PR is in the database: only a running one asks gh.
|
|
306
308
|
const chat = session.view === "chat";
|
|
309
|
+
// A quick change has no agent: the orchestrator is working on it until
|
|
310
|
+
// its PR is up, and nothing in it can be blocked.
|
|
311
|
+
const quick = !!session.quick_of;
|
|
307
312
|
const [pr, chatTurn, screen] = await Promise.all([
|
|
308
313
|
live ? prFor(session) : storedPR(session),
|
|
309
|
-
live && chat
|
|
314
|
+
live && chat && !quick
|
|
310
315
|
? chatStateNow(session.id).catch((): ChatTurn => "unknown")
|
|
311
316
|
: Promise.resolve(null),
|
|
312
|
-
live && !chat
|
|
317
|
+
live && !chat && !quick
|
|
313
318
|
? statusDetector.getStatus(session.tmux_name)
|
|
314
319
|
: Promise.resolve(undefined),
|
|
315
320
|
]);
|
|
316
321
|
const sessionStatus = !live
|
|
317
322
|
? undefined
|
|
318
|
-
:
|
|
319
|
-
?
|
|
320
|
-
:
|
|
323
|
+
: quick
|
|
324
|
+
? "running"
|
|
325
|
+
: chat
|
|
326
|
+
? chatStatus(chatTurn)
|
|
327
|
+
: screen;
|
|
321
328
|
const agentGone =
|
|
322
|
-
!chat &&
|
|
329
|
+
!chat &&
|
|
330
|
+
!quick &&
|
|
331
|
+
live &&
|
|
332
|
+
sessionStatus !== "dead" &&
|
|
333
|
+
sessionStatus !== undefined
|
|
323
334
|
? await shellOnly(session.tmux_name)
|
|
324
335
|
: false;
|
|
325
336
|
const blocked =
|
|
@@ -365,6 +376,7 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
365
376
|
armed: !!session.merge_armed_at,
|
|
366
377
|
model: sessionModelRef(session),
|
|
367
378
|
planning: session.plan_phase === "planning",
|
|
379
|
+
quickOf: session.quick_of ?? null,
|
|
368
380
|
};
|
|
369
381
|
}
|
|
370
382
|
|
package/lib/tasks/move-guard.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { db, type Session } from "../db";
|
|
|
10
10
|
import { escalations } from "../orchestrator/gates";
|
|
11
11
|
|
|
12
12
|
export function moveRefusal(session: Session): string | null {
|
|
13
|
+
if (session.quick_of)
|
|
14
|
+
return "It's a quick change its orchestrator makes on this machine, so it stays here";
|
|
13
15
|
if (escalations(session.id).length)
|
|
14
16
|
return "It's waiting on you (an escalated gate), so it stays here";
|
|
15
17
|
const ask = db
|
package/lib/tasks/move.ts
CHANGED
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import os from "os";
|
|
10
|
-
import { execFile } from "child_process";
|
|
11
|
-
import { promisify } from "util";
|
|
12
10
|
import { db, type Session } from "../db";
|
|
13
11
|
import { getProject } from "../projects";
|
|
12
|
+
import { endTmuxSession } from "../tmux/end";
|
|
14
13
|
import { launchClaude } from "../agents/launch";
|
|
15
14
|
import { buildTaskBrief } from "./brief";
|
|
16
15
|
import { git as runGit } from "../git-exec";
|
|
@@ -31,7 +30,6 @@ import {
|
|
|
31
30
|
|
|
32
31
|
export * from "./move-bundle";
|
|
33
32
|
|
|
34
|
-
const execFileAsync = promisify(execFile);
|
|
35
33
|
const git = (cwd: string, ...args: string[]) =>
|
|
36
34
|
runGit(cwd, args, { timeout: 120000 });
|
|
37
35
|
|
|
@@ -40,9 +38,7 @@ const g = globalThis as unknown as { __agentosExporting?: Set<string> };
|
|
|
40
38
|
const exporting = (g.__agentosExporting ??= new Set());
|
|
41
39
|
|
|
42
40
|
async function stopAgent(tmuxName: string): Promise<void> {
|
|
43
|
-
await
|
|
44
|
-
() => undefined
|
|
45
|
-
);
|
|
41
|
+
await endTmuxSession(null, tmuxName);
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
export async function claudeIdFor(session: Session): Promise<string | null> {
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -110,7 +110,8 @@ const workspaceOf = (projectId: string) =>
|
|
|
110
110
|
getProject(projectId)?.workspace_id ?? null;
|
|
111
111
|
|
|
112
112
|
// Running tasks hold their slot until they finish (merged, dropped, done),
|
|
113
|
-
// PR open or not; a queued task being started counts too.
|
|
113
|
+
// PR open or not; a queued task being started counts too. A quick change
|
|
114
|
+
// runs no agent, so it holds none. Without a
|
|
114
115
|
// workspace, the project is the scope.
|
|
115
116
|
function runningIn(scope: { workspaceId: string | null; projectId: string }) {
|
|
116
117
|
const where = scope.workspaceId
|
|
@@ -120,7 +121,7 @@ function runningIn(scope: { workspaceId: string | null; projectId: string }) {
|
|
|
120
121
|
db
|
|
121
122
|
.prepare(
|
|
122
123
|
`SELECT s.id FROM sessions s JOIN projects p ON p.id = s.project_id
|
|
123
|
-
WHERE ${where} AND s.archived_at IS NULL
|
|
124
|
+
WHERE ${where} AND s.archived_at IS NULL AND s.quick_of IS NULL
|
|
124
125
|
AND s.task_status IN (${IN_FLIGHT.map((s) => `'${s}'`).join(", ")})`
|
|
125
126
|
)
|
|
126
127
|
.all({ ws: scope.workspaceId, project: scope.projectId }) as {
|