@saadnvd1/agent-os 0.3.534 → 0.3.537
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 +19 -3
- package/app/page.tsx +1 -1
- package/components/Pane/index.tsx +29 -21
- package/components/Schedules/ScheduleForm.tsx +20 -2
- package/components/Terminal/SignInCard.tsx +84 -0
- package/components/Welcome/AgentList.tsx +3 -1
- package/components/views/types.ts +2 -0
- package/data/statuses/useSignIn.ts +16 -0
- package/lib/__fixtures__/screens/kiro-device-login.txt +10 -0
- package/lib/agent-options.ts +2 -0
- package/lib/agents/probe.ts +36 -10
- package/lib/agents/setup-commands.ts +9 -0
- package/lib/agents/signin-screen.ts +109 -0
- package/lib/banner.ts +7 -4
- package/lib/db/migrations.ts +16 -0
- package/lib/db/schedules-move.ts +220 -0
- package/lib/orchestration.ts +1 -1
- package/lib/orchestrator/external-gates.ts +5 -1
- package/lib/orchestrator/held-review.ts +18 -0
- package/lib/orchestrator/review.ts +8 -4
- package/lib/providers/registry.ts +43 -0
- package/lib/providers.ts +19 -1
- package/lib/start-options.ts +5 -1
- package/lib/status/collect.ts +36 -0
- package/package.json +1 -1
- package/scripts/revert-schedules-move.ts +24 -0
- package/scripts/setup.sh +2 -2
package/README.md
CHANGED
|
@@ -134,7 +134,8 @@ Pi,
|
|
|
134
134
|
[Kilo Code CLI](https://kilo.ai/docs/cli),
|
|
135
135
|
[Gemini CLI](https://github.com/google-gemini/gemini-cli),
|
|
136
136
|
[Aider](https://aider.chat/), [Cursor CLI](https://cursor.com/cli), Amp or
|
|
137
|
-
[GitHub Copilot CLI](https://github.com/github/copilot-cli)
|
|
137
|
+
[GitHub Copilot CLI](https://github.com/github/copilot-cli) or
|
|
138
|
+
[Kiro CLI](https://kiro.dev/docs/cli/).
|
|
138
139
|
|
|
139
140
|
```bash
|
|
140
141
|
npm install -g @saadnvd1/agent-os
|
|
@@ -250,10 +251,21 @@ CLI) with the fix for anything missing (`/api/doctor`).
|
|
|
250
251
|
| Amp | ❌ | ✅ | ❌ | `--dangerously-allow-all` |
|
|
251
252
|
| Oh My Pi | ❌ | ❌ | ❌ | N/A |
|
|
252
253
|
| GitHub Copilot | ✅ | ✅ | ❌ | `--allow-all` |
|
|
254
|
+
| Kiro CLI | ❌ | ❌ | ❌ | `--trust-all-tools` |
|
|
255
|
+
| Amazon Q (old) | ❌ | ❌ | ❌ | `--trust-all-tools` |
|
|
253
256
|
|
|
254
257
|
Agents without chat run in the terminal. The new-session picker says when an
|
|
255
258
|
agent isn't installed or needs a sign-in first, and what to run.
|
|
256
259
|
|
|
260
|
+
Kiro CLI (and the older Amazon Q CLI, `q`, shown only where it is installed)
|
|
261
|
+
signs in on its own: a session first checks `whoami`, and if you're signed out
|
|
262
|
+
runs `login --use-device-flow`. AgentOS reads the link and one-time code off
|
|
263
|
+
the screen and shows them in a card above the terminal, with a push when it's
|
|
264
|
+
waiting, so you can finish on your phone. No credential passes through
|
|
265
|
+
AgentOS. Identity Center sign-in (including Microsoft or Okta federation) works
|
|
266
|
+
this way; a login through an external identity provider doesn't support the
|
|
267
|
+
device flow, so sign in once on the machine itself, or set `KIRO_API_KEY`.
|
|
268
|
+
|
|
257
269
|
### CLI
|
|
258
270
|
|
|
259
271
|
```bash
|
|
@@ -969,8 +981,12 @@ name, a time (presets for hourly, daily, weekdays and weekly, or any
|
|
|
969
981
|
five-field cron expression, in America/Chicago unless you pick another zone),
|
|
970
982
|
a project and a prompt, and starts one of:
|
|
971
983
|
|
|
972
|
-
- **Task**:
|
|
973
|
-
|
|
984
|
+
- **Task** and **Session**: now recipes (Settings > Recipes) with a timer.
|
|
985
|
+
Migration 81 turned each existing recurring task or session schedule into a
|
|
986
|
+
recipe, with its run history; the Schedules form no longer offers them. A
|
|
987
|
+
one-off that still has one of these kinds is left as it was. To undo the
|
|
988
|
+
move, stop AgentOS and run
|
|
989
|
+
`npx tsx scripts/revert-schedules-move.ts --db <path to agent-os.db>`.
|
|
974
990
|
- **Orchestrator**: the prompt, posted to the workspace's orchestrator as a
|
|
975
991
|
message from "Schedule <name>", marked as a scheduled prompt so it never
|
|
976
992
|
counts as an approval.
|
package/app/page.tsx
CHANGED
|
@@ -302,7 +302,7 @@ function HomeContent() {
|
|
|
302
302
|
isLocal && provider.id === "claude"
|
|
303
303
|
? ` --append-system-prompt-file "$HOME/.agent-os/bus-brief.md" ${CLAUDE_STATUS_SETTINGS_FLAG}`
|
|
304
304
|
: "";
|
|
305
|
-
const agentCmd = `${provider.command} ${flagsStr}${busBrief}`;
|
|
305
|
+
const agentCmd = `${provider.preamble}${provider.command} ${flagsStr}${busBrief}`;
|
|
306
306
|
// The init script is a file on this machine, so remote sessions run the
|
|
307
307
|
// agent directly and drop to a shell when it exits.
|
|
308
308
|
const command =
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useTaskLogQuery } from "@/data/session-logs";
|
|
4
4
|
import { SetupCard } from "@/components/Chat/SetupCard";
|
|
5
|
+
import { SignInCard } from "@/components/Terminal/SignInCard";
|
|
6
|
+
import { getProviderDefinition } from "@/lib/providers/registry";
|
|
5
7
|
import type { TabData } from "@/lib/panes";
|
|
6
8
|
import { useRef, useCallback, useEffect, memo, useMemo } from "react";
|
|
7
9
|
import dynamic from "next/dynamic";
|
|
@@ -322,28 +324,34 @@ export const Pane = memo(function Pane({
|
|
|
322
324
|
</div>
|
|
323
325
|
);
|
|
324
326
|
const savedState = sessionRegistry.getTerminalState(paneId, tab.id);
|
|
327
|
+
const signInAgent = own ? getProviderDefinition(own.agent_type) : undefined;
|
|
325
328
|
return (
|
|
326
|
-
<
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
329
|
+
<div className="relative h-full w-full">
|
|
330
|
+
{signInAgent?.signIn && own && (
|
|
331
|
+
<SignInCard sessionId={own.id} agent={signInAgent.name} />
|
|
332
|
+
)}
|
|
333
|
+
<Terminal
|
|
334
|
+
ref={getTerminalRef(tab.id)}
|
|
335
|
+
onConnected={getTerminalConnectedHandler(tab)}
|
|
336
|
+
onBeforeUnmount={(scrollState) => {
|
|
337
|
+
sessionRegistry.saveTerminalState(paneId, tab.id, {
|
|
338
|
+
scrollTop: scrollState.scrollTop,
|
|
339
|
+
scrollHeight: 0,
|
|
340
|
+
lastActivity: Date.now(),
|
|
341
|
+
cursorY: scrollState.cursorY,
|
|
342
|
+
});
|
|
343
|
+
}}
|
|
344
|
+
initialScrollState={
|
|
345
|
+
savedState
|
|
346
|
+
? {
|
|
347
|
+
scrollTop: savedState.scrollTop,
|
|
348
|
+
cursorY: savedState.cursorY,
|
|
349
|
+
baseY: 0,
|
|
350
|
+
}
|
|
351
|
+
: undefined
|
|
352
|
+
}
|
|
353
|
+
/>
|
|
354
|
+
</div>
|
|
347
355
|
);
|
|
348
356
|
};
|
|
349
357
|
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
wall,
|
|
35
35
|
type PresetKind,
|
|
36
36
|
} from "@/lib/schedules/cron";
|
|
37
|
+
import { recipesUiActions } from "@/stores/recipesUi";
|
|
37
38
|
import { parseWhen, relativeTime } from "@/lib/schedules/when";
|
|
38
39
|
|
|
39
40
|
const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
|
|
@@ -59,6 +60,10 @@ const KINDS: { value: ScheduleKind; label: string; hint: string }[] = [
|
|
|
59
60
|
},
|
|
60
61
|
];
|
|
61
62
|
|
|
63
|
+
// New jobs of these shapes are recipes; the kinds stay only to edit a
|
|
64
|
+
// schedule that still has one (a one-off, say).
|
|
65
|
+
const LEGACY_KINDS: ScheduleKind[] = ["task", "session"];
|
|
66
|
+
|
|
62
67
|
type Preset = PresetKind | "custom" | "once";
|
|
63
68
|
|
|
64
69
|
const PRESETS: { value: Preset; label: string }[] = [
|
|
@@ -171,7 +176,7 @@ export function ScheduleForm({
|
|
|
171
176
|
);
|
|
172
177
|
const [name, setName] = useState(existing?.name ?? draft?.name ?? "");
|
|
173
178
|
const [kind, setKind] = useState<ScheduleKind>(
|
|
174
|
-
existing?.kind ?? draft?.kind ?? "
|
|
179
|
+
existing?.kind ?? draft?.kind ?? "orchestrator"
|
|
175
180
|
);
|
|
176
181
|
const [targetId, setTargetId] = useState(
|
|
177
182
|
existing?.target_session_id ?? draft?.targetSessionId ?? ""
|
|
@@ -329,7 +334,9 @@ export function ScheduleForm({
|
|
|
329
334
|
<Segmented
|
|
330
335
|
label="What it starts"
|
|
331
336
|
value={kind}
|
|
332
|
-
options={KINDS
|
|
337
|
+
options={KINDS.filter(
|
|
338
|
+
(k) => !LEGACY_KINDS.includes(k.value) || existing?.kind === k.value
|
|
339
|
+
)}
|
|
333
340
|
onChange={(k) => {
|
|
334
341
|
setKind(k);
|
|
335
342
|
if (preset === "every" && !frequent(k)) setPreset("daily");
|
|
@@ -338,6 +345,17 @@ export function ScheduleForm({
|
|
|
338
345
|
<p className="text-muted-foreground text-xs">
|
|
339
346
|
{KINDS.find((k) => k.value === kind)?.hint}
|
|
340
347
|
</p>
|
|
348
|
+
<p className="text-muted-foreground text-xs">
|
|
349
|
+
To start a task or session on a timer, use a{" "}
|
|
350
|
+
<button
|
|
351
|
+
type="button"
|
|
352
|
+
className="text-foreground underline underline-offset-2"
|
|
353
|
+
onClick={() => recipesUiActions.open(workspaceId ?? null)}
|
|
354
|
+
>
|
|
355
|
+
recipe
|
|
356
|
+
</button>
|
|
357
|
+
.
|
|
358
|
+
</p>
|
|
341
359
|
</Field>
|
|
342
360
|
|
|
343
361
|
{!workspaceId && (
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ExternalLink, X } from "lucide-react";
|
|
5
|
+
import { useSignIn } from "@/data/statuses/useSignIn";
|
|
6
|
+
import { trustedSignInUrl } from "@/lib/agents/signin-screen";
|
|
7
|
+
import { CopyButton } from "@/components/Chat/CopyButton";
|
|
8
|
+
|
|
9
|
+
// Finishing an agent's sign-in from this device: the link to open and the
|
|
10
|
+
// code to type there. The text is the CLI's own, so a link is only opened
|
|
11
|
+
// when it points at a sign-in page; anything else stays text to copy.
|
|
12
|
+
export function SignInCard({
|
|
13
|
+
sessionId,
|
|
14
|
+
agent,
|
|
15
|
+
}: {
|
|
16
|
+
sessionId: string;
|
|
17
|
+
agent: string;
|
|
18
|
+
}) {
|
|
19
|
+
const signIn = useSignIn(sessionId);
|
|
20
|
+
// Hidden until the sign-in shows something new.
|
|
21
|
+
const [hidden, setHidden] = useState("");
|
|
22
|
+
if (!signIn) return null;
|
|
23
|
+
const { url, code } = signIn;
|
|
24
|
+
const key = `${url}|${code}`;
|
|
25
|
+
if (hidden === key) return null;
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
role="status"
|
|
29
|
+
className="bg-card hairline absolute inset-x-2 top-2 z-10 flex flex-col gap-3 rounded-lg p-3 shadow-lg"
|
|
30
|
+
>
|
|
31
|
+
<div>
|
|
32
|
+
<div className="flex items-center justify-between gap-2">
|
|
33
|
+
<p className="text-sm font-medium">Sign in to {agent}</p>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
aria-label="Dismiss"
|
|
37
|
+
onClick={() => setHidden(key)}
|
|
38
|
+
className="text-muted-foreground hover:text-foreground -m-2 flex h-11 w-11 items-center justify-center"
|
|
39
|
+
>
|
|
40
|
+
<X className="size-4" />
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
<p className="text-muted-foreground text-xs">
|
|
44
|
+
Finish signing in on any device; the session carries on by itself.
|
|
45
|
+
</p>
|
|
46
|
+
</div>
|
|
47
|
+
{code && (
|
|
48
|
+
<div className="flex items-center justify-between gap-2">
|
|
49
|
+
<span className="font-mono text-2xl tracking-widest select-all">
|
|
50
|
+
{code}
|
|
51
|
+
</span>
|
|
52
|
+
<CopyButton
|
|
53
|
+
text={code}
|
|
54
|
+
label="Copy code"
|
|
55
|
+
className="h-11 w-11 md:h-11 md:w-11"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
{url &&
|
|
60
|
+
(trustedSignInUrl(url) ? (
|
|
61
|
+
<a
|
|
62
|
+
href={url}
|
|
63
|
+
target="_blank"
|
|
64
|
+
rel="noopener noreferrer"
|
|
65
|
+
className="bg-primary text-primary-foreground flex h-11 items-center justify-center gap-2 rounded-lg text-sm font-medium"
|
|
66
|
+
>
|
|
67
|
+
Open sign-in page
|
|
68
|
+
<ExternalLink className="size-4" />
|
|
69
|
+
</a>
|
|
70
|
+
) : (
|
|
71
|
+
<div className="flex items-center gap-2">
|
|
72
|
+
<span className="text-muted-foreground min-w-0 flex-1 text-xs break-all select-all">
|
|
73
|
+
{url}
|
|
74
|
+
</span>
|
|
75
|
+
<CopyButton
|
|
76
|
+
text={url}
|
|
77
|
+
label="Copy link"
|
|
78
|
+
className="h-11 w-11 md:h-11 md:w-11"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -23,7 +23,9 @@ export function AgentList() {
|
|
|
23
23
|
|
|
24
24
|
useEffect(() => mutate(), [mutate]);
|
|
25
25
|
|
|
26
|
-
const providers = getAllProviderDefinitions().filter(
|
|
26
|
+
const providers = getAllProviderDefinitions().filter(
|
|
27
|
+
(p) => p.cli && (!p.legacy || agents?.[p.id]?.installed)
|
|
28
|
+
);
|
|
27
29
|
const shown = providers.filter((p) => all || FEATURED_AGENTS.includes(p.id));
|
|
28
30
|
const hidden = providers.length - shown.length;
|
|
29
31
|
|
|
@@ -20,6 +20,8 @@ export interface SessionStatus {
|
|
|
20
20
|
claudeSessionId?: string | null;
|
|
21
21
|
// From a program's OSC 7501 report: untrusted, rendered as plain text.
|
|
22
22
|
detail?: string | null;
|
|
23
|
+
// The link and code a CLI's sign-in is waiting on (untrusted text).
|
|
24
|
+
signIn?: { url: string | null; code: string | null } | null;
|
|
23
25
|
progress?: number | null;
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { SessionStatus } from "@/components/views/types";
|
|
3
|
+
import { statusKeys } from "../sessions/keys";
|
|
4
|
+
import { fetchStatuses } from "./queries";
|
|
5
|
+
|
|
6
|
+
// The sign-in a session's CLI is waiting on, read from the statuses the
|
|
7
|
+
// sidebar already keeps; asks for nothing of its own.
|
|
8
|
+
export function useSignIn(sessionId: string): SessionStatus["signIn"] {
|
|
9
|
+
const { data } = useQuery({
|
|
10
|
+
queryKey: statusKeys.all,
|
|
11
|
+
queryFn: fetchStatuses,
|
|
12
|
+
staleTime: 2000,
|
|
13
|
+
select: (d) => d.statuses[sessionId]?.signIn ?? null,
|
|
14
|
+
});
|
|
15
|
+
return data;
|
|
16
|
+
}
|
package/lib/agent-options.ts
CHANGED
|
@@ -13,6 +13,8 @@ export const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
|
|
|
13
13
|
{ value: "pi", label: "Pi" },
|
|
14
14
|
{ value: "omp", label: "Oh My Pi" },
|
|
15
15
|
{ value: "copilot", label: "GitHub Copilot" },
|
|
16
|
+
{ value: "kiro", label: "Kiro CLI" },
|
|
17
|
+
{ value: "amazonq", label: "Amazon Q CLI (legacy)" },
|
|
16
18
|
];
|
|
17
19
|
|
|
18
20
|
export const agentLabel = (agent: AgentType) =>
|
package/lib/agents/probe.ts
CHANGED
|
@@ -87,6 +87,31 @@ function claudeAuth(env: NodeJS.ProcessEnv): AgentAuth {
|
|
|
87
87
|
return hasJsonKeys(config) ? "needs-login" : "unknown";
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// Signed in when `cli args` exits 0. Timing out or failing to start tells
|
|
91
|
+
// nothing; any other exit code is the CLI saying it isn't signed in.
|
|
92
|
+
async function exitAuth(
|
|
93
|
+
cli: string,
|
|
94
|
+
env: NodeJS.ProcessEnv,
|
|
95
|
+
args: string[],
|
|
96
|
+
signedOut: (code: unknown) => boolean
|
|
97
|
+
): Promise<AgentAuth> {
|
|
98
|
+
try {
|
|
99
|
+
await run(cli, args, { timeout: PROBE_MS, env });
|
|
100
|
+
return "ready";
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return signedOut((error as { code?: unknown }).code)
|
|
103
|
+
? "needs-login"
|
|
104
|
+
: "unknown";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Kiro's `whoami` is never read, only its exit code: its output names the
|
|
109
|
+
// account.
|
|
110
|
+
const kiroAuth: AuthCheck = (cli, env) =>
|
|
111
|
+
anyEnv(env, ["KIRO_API_KEY"])
|
|
112
|
+
? "ready"
|
|
113
|
+
: exitAuth(cli, env, ["whoami"], (c) => typeof c === "number" && c !== 0);
|
|
114
|
+
|
|
90
115
|
const AUTH: Partial<Record<ProviderId, { check: AuthCheck; hint: string }>> = {
|
|
91
116
|
claude: {
|
|
92
117
|
check: (_cli, env) => claudeAuth(env),
|
|
@@ -94,16 +119,8 @@ const AUTH: Partial<Record<ProviderId, { check: AuthCheck; hint: string }>> = {
|
|
|
94
119
|
},
|
|
95
120
|
codex: {
|
|
96
121
|
// Exits 0 signed in, 1 not.
|
|
97
|
-
check:
|
|
98
|
-
|
|
99
|
-
await run(cli, ["login", "status"], { timeout: PROBE_MS, env });
|
|
100
|
-
return "ready";
|
|
101
|
-
} catch (error) {
|
|
102
|
-
return (error as { code?: unknown }).code === 1
|
|
103
|
-
? "needs-login"
|
|
104
|
-
: "unknown";
|
|
105
|
-
}
|
|
106
|
-
},
|
|
122
|
+
check: (cli, env) =>
|
|
123
|
+
exitAuth(cli, env, ["login", "status"], (c) => c === 1),
|
|
107
124
|
hint: "Run `codex login` in a terminal",
|
|
108
125
|
},
|
|
109
126
|
// OpenCode's own free models need no sign-in.
|
|
@@ -153,6 +170,15 @@ const AUTH: Partial<Record<ProviderId, { check: AuthCheck; hint: string }>> = {
|
|
|
153
170
|
: "needs-login",
|
|
154
171
|
hint: "Run `cursor-agent login` in a terminal",
|
|
155
172
|
},
|
|
173
|
+
kiro: {
|
|
174
|
+
check: kiroAuth,
|
|
175
|
+
hint: "Run `kiro-cli login --use-device-flow` in a terminal, or set KIRO_API_KEY",
|
|
176
|
+
},
|
|
177
|
+
amazonq: {
|
|
178
|
+
check: (cli, env) =>
|
|
179
|
+
exitAuth(cli, env, ["whoami"], (c) => typeof c === "number" && c !== 0),
|
|
180
|
+
hint: "Run `q login --use-device-flow` in a terminal",
|
|
181
|
+
},
|
|
156
182
|
amp: {
|
|
157
183
|
check: (_cli, env) =>
|
|
158
184
|
hasJsonKeys(home(".local", "share", "amp", "secrets.json")) ||
|
|
@@ -34,6 +34,15 @@ export const SETUP_COMMANDS: Partial<Record<ProviderId, SetupCommands>> = {
|
|
|
34
34
|
install: "npm install -g @github/copilot",
|
|
35
35
|
signIn: "copilot login",
|
|
36
36
|
},
|
|
37
|
+
kiro: {
|
|
38
|
+
install: "curl -fsSL https://cli.kiro.dev/install | bash",
|
|
39
|
+
signIn: "kiro-cli login --use-device-flow",
|
|
40
|
+
docs: "https://kiro.dev/docs/cli/",
|
|
41
|
+
},
|
|
42
|
+
amazonq: {
|
|
43
|
+
signIn: "q login --use-device-flow",
|
|
44
|
+
docs: "https://kiro.dev/docs/cli/",
|
|
45
|
+
},
|
|
37
46
|
// No installer line we can vouch for from here; its own site has it.
|
|
38
47
|
opencode: { docs: "https://opencode.ai/docs" },
|
|
39
48
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sign-in a CLI is waiting on, read from its screen: the link to open
|
|
3
|
+
* and the one-time code to type there (a device flow), so a device can
|
|
4
|
+
* finish it. Only the stretch of screen between the two lines a session
|
|
5
|
+
* prints around its login is read, so the agent's own output (links and the
|
|
6
|
+
* word "code" are everywhere in it) is never taken for a sign-in. What's
|
|
7
|
+
* there is the CLI's own output, so nothing is trusted: a link is only
|
|
8
|
+
* offered to open when its host is a sign-in page.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Printed by the session before the login and after it, whatever it did.
|
|
12
|
+
export const SIGN_IN_START = "AgentOS: signing in";
|
|
13
|
+
export const SIGN_IN_END = "AgentOS: sign-in finished";
|
|
14
|
+
|
|
15
|
+
export interface SignInScreen {
|
|
16
|
+
url: string | null;
|
|
17
|
+
code: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CUE = /verif|authori[sz]|one.?time|confirm|enter|code|open/i;
|
|
21
|
+
const DONE = /logged in|signed in|success/i;
|
|
22
|
+
const URL_LINE = /https:\/\/\S+/;
|
|
23
|
+
const CODE_IN_URL = /user_code=([A-Z0-9-]{6,12})/;
|
|
24
|
+
const CODE_LINE = /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/;
|
|
25
|
+
const BARE_CODE = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
|
26
|
+
const URL_PIECE = /^[\w\-./?=&%#:+~@]+$/;
|
|
27
|
+
// A URL cut by the pane's width: the piece before the cut ran this long.
|
|
28
|
+
const WRAPPED_AT = 30;
|
|
29
|
+
const LOOKBACK = 30;
|
|
30
|
+
const NEAR = 3;
|
|
31
|
+
|
|
32
|
+
// Where a device flow sends you: AWS's device-authorization page, the
|
|
33
|
+
// Builder ID and Identity Center portals, and Kiro's own login. Not every
|
|
34
|
+
// AWS host: S3 buckets and API gateways are anyone's.
|
|
35
|
+
const TRUSTED_HOSTS = [
|
|
36
|
+
/^device\.sso\.[a-z0-9-]+\.amazonaws\.com$/,
|
|
37
|
+
/^view\.awsapps\.com$/,
|
|
38
|
+
/^d-[a-z0-9]+\.awsapps\.com$/,
|
|
39
|
+
/^([a-z0-9-]+\.)?kiro\.dev$/,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
export function trustedSignInUrl(url: string): boolean {
|
|
43
|
+
try {
|
|
44
|
+
const u = new URL(url);
|
|
45
|
+
return (
|
|
46
|
+
u.protocol === "https:" &&
|
|
47
|
+
!u.username &&
|
|
48
|
+
!u.password &&
|
|
49
|
+
TRUSTED_HOSTS.some((h) => h.test(u.hostname))
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Lines of the screen, with a URL the pane cut across lines put back.
|
|
57
|
+
function joined(lines: string[]): string[] {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
let line = lines[i].trim();
|
|
61
|
+
const at = line.search(URL_LINE);
|
|
62
|
+
if (at >= 0 && !/\s/.test(line.slice(at))) {
|
|
63
|
+
let last = line.slice(at);
|
|
64
|
+
for (;;) {
|
|
65
|
+
const next = lines[i + 1]?.trim() ?? "";
|
|
66
|
+
if (
|
|
67
|
+
last.length < WRAPPED_AT ||
|
|
68
|
+
!URL_PIECE.test(next) ||
|
|
69
|
+
BARE_CODE.test(next)
|
|
70
|
+
)
|
|
71
|
+
break;
|
|
72
|
+
line += next;
|
|
73
|
+
last = next;
|
|
74
|
+
i++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
out.push(line);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function findSignIn(text: string): SignInScreen | null {
|
|
83
|
+
const all = text.split("\n");
|
|
84
|
+
const start = all.findLastIndex((l) => l.includes(SIGN_IN_START));
|
|
85
|
+
if (start < 0) return null;
|
|
86
|
+
const during = all.slice(start + 1);
|
|
87
|
+
if (during.some((l) => l.includes(SIGN_IN_END))) return null;
|
|
88
|
+
const lines = joined(during.slice(-LOOKBACK));
|
|
89
|
+
let found: { at: number; url: string } | null = null;
|
|
90
|
+
lines.forEach((line, at) => {
|
|
91
|
+
const url = line.match(URL_LINE)?.[0].replace(/[).,;]+$/, "");
|
|
92
|
+
if (!url) return;
|
|
93
|
+
const near = lines.slice(Math.max(0, at - NEAR), at + NEAR + 1);
|
|
94
|
+
if (near.some((l) => CUE.test(l))) found = { at, url };
|
|
95
|
+
});
|
|
96
|
+
if (!found) return null;
|
|
97
|
+
const { at, url } = found as { at: number; url: string };
|
|
98
|
+
if (lines.slice(at + 1).some((l) => DONE.test(l))) return null;
|
|
99
|
+
let code = url.match(CODE_IN_URL)?.[1] ?? null;
|
|
100
|
+
if (!code)
|
|
101
|
+
for (const l of lines.slice(Math.max(0, at - NEAR), at + NEAR + 1)) {
|
|
102
|
+
const m = l.match(CODE_LINE);
|
|
103
|
+
if (m && l !== url) {
|
|
104
|
+
code = m[1];
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { url, code };
|
|
109
|
+
}
|
package/lib/banner.ts
CHANGED
|
@@ -8,7 +8,10 @@ import * as path from "path";
|
|
|
8
8
|
/**
|
|
9
9
|
* Generate an init script that shows the AgentOS banner and configures tmux
|
|
10
10
|
*/
|
|
11
|
-
function generateInitScript(
|
|
11
|
+
export function generateInitScript(
|
|
12
|
+
agentCommand: string,
|
|
13
|
+
preamble = ""
|
|
14
|
+
): string {
|
|
12
15
|
return `#!/bin/bash
|
|
13
16
|
# AgentOS Session Init Script
|
|
14
17
|
# Auto-generated - do not edit manually
|
|
@@ -47,15 +50,15 @@ printf "\\n"
|
|
|
47
50
|
sleep 0.8
|
|
48
51
|
|
|
49
52
|
# Start the agent
|
|
50
|
-
exec ${agentCommand}
|
|
53
|
+
${preamble}exec ${agentCommand}
|
|
51
54
|
`;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
/**
|
|
55
58
|
* Write init script to temp file and return the command to run it
|
|
56
59
|
*/
|
|
57
|
-
export function wrapWithBanner(agentCommand: string): string {
|
|
58
|
-
const scriptContent = generateInitScript(agentCommand);
|
|
60
|
+
export function wrapWithBanner(agentCommand: string, preamble = ""): string {
|
|
61
|
+
const scriptContent = generateInitScript(agentCommand, preamble);
|
|
59
62
|
const scriptPath = path.join(os.tmpdir(), `agent-os-init-${Date.now()}.sh`);
|
|
60
63
|
|
|
61
64
|
fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
|
package/lib/db/migrations.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
TABLES_WATCHED_80,
|
|
9
9
|
} from "./changes";
|
|
10
10
|
import type Database from "better-sqlite3";
|
|
11
|
+
import { moveSchedulesToRecipes } from "./schedules-move";
|
|
11
12
|
|
|
12
13
|
interface Migration {
|
|
13
14
|
id: number;
|
|
@@ -1957,6 +1958,21 @@ const migrations: Migration[] = [
|
|
|
1957
1958
|
installChangeTriggers(db, TABLES_WATCHED_80);
|
|
1958
1959
|
},
|
|
1959
1960
|
},
|
|
1961
|
+
{
|
|
1962
|
+
id: 81,
|
|
1963
|
+
name: "schedules_into_recipes",
|
|
1964
|
+
up: (db) => {
|
|
1965
|
+
// Live task and session schedules become recipes with a timer, and
|
|
1966
|
+
// their runs become recipe runs (lib/db/schedules-move). Reversible:
|
|
1967
|
+
// scripts/revert-schedules-move.ts.
|
|
1968
|
+
const cols = (
|
|
1969
|
+
db.prepare(`PRAGMA table_info(schedules)`).all() as { name: string }[]
|
|
1970
|
+
).map((c) => c.name);
|
|
1971
|
+
if (!cols.includes("recipe_id") || !cols.includes("migrated_from_kind"))
|
|
1972
|
+
throw new Error("migration 80 hasn't run");
|
|
1973
|
+
moveSchedulesToRecipes(db);
|
|
1974
|
+
},
|
|
1975
|
+
},
|
|
1960
1976
|
];
|
|
1961
1977
|
|
|
1962
1978
|
// `upTo`: stop after this id (tests that start from an older database).
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Migration 81: live task and session schedules become recipes with a timer
|
|
2
|
+
// (lib/recipes), and their runs become recipe runs. `migrated_from_kind` on
|
|
3
|
+
// the schedule row is what makes it reversible: revertSchedulesMove.
|
|
4
|
+
//
|
|
5
|
+
// A one-off (run_at) and an archived schedule are left as they are: a timer
|
|
6
|
+
// repeats, and history of archived ones stays where it was.
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
9
|
+
import type Database from "better-sqlite3";
|
|
10
|
+
|
|
11
|
+
interface ScheduleRow {
|
|
12
|
+
id: string;
|
|
13
|
+
workspace_id: string;
|
|
14
|
+
project_id: string | null;
|
|
15
|
+
name: string;
|
|
16
|
+
prompt: string;
|
|
17
|
+
kind: "task" | "session";
|
|
18
|
+
created_by_session_id: string | null;
|
|
19
|
+
created_at: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RunRow {
|
|
23
|
+
id: number;
|
|
24
|
+
trigger: string;
|
|
25
|
+
session_id: string;
|
|
26
|
+
created_at: string;
|
|
27
|
+
task_status: string | null;
|
|
28
|
+
pr_number: number | null;
|
|
29
|
+
pr_url: string | null;
|
|
30
|
+
session_exists: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TRIGGER: Record<string, string> = {
|
|
34
|
+
schedule: "timer",
|
|
35
|
+
"catch-up": "catch-up",
|
|
36
|
+
manual: "manual",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const FINAL: Record<string, string> = {
|
|
40
|
+
merged: "succeeded",
|
|
41
|
+
done: "succeeded",
|
|
42
|
+
dropped: "failed",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// The name is unique among a workspace's live recipes, ignoring case.
|
|
46
|
+
function freeName(
|
|
47
|
+
db: Database.Database,
|
|
48
|
+
workspaceId: string,
|
|
49
|
+
name: string
|
|
50
|
+
): string {
|
|
51
|
+
const taken = db.prepare(
|
|
52
|
+
`SELECT 1 FROM recipes WHERE workspace_id = ? AND archived_at IS NULL AND name = ? COLLATE NOCASE`
|
|
53
|
+
);
|
|
54
|
+
if (!taken.get(workspaceId, name)) return name;
|
|
55
|
+
for (let n = 1; ; n++) {
|
|
56
|
+
const candidate = `${name} (schedule${n > 1 ? ` ${n}` : ""})`;
|
|
57
|
+
if (!taken.get(workspaceId, candidate)) return candidate;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function moveSchedulesToRecipes(db: Database.Database): {
|
|
62
|
+
schedules: number;
|
|
63
|
+
runs: number;
|
|
64
|
+
} {
|
|
65
|
+
const sessionCols = (
|
|
66
|
+
db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
|
|
67
|
+
).map((c) => c.name);
|
|
68
|
+
const pr = sessionCols.includes("pr_number")
|
|
69
|
+
? "s.pr_number, s.pr_url"
|
|
70
|
+
: "NULL AS pr_number, NULL AS pr_url";
|
|
71
|
+
const hasTaskStatus = sessionCols.includes("task_status");
|
|
72
|
+
|
|
73
|
+
const schedules = db
|
|
74
|
+
.prepare(
|
|
75
|
+
`SELECT id, workspace_id, project_id, name, prompt, kind, created_by_session_id, created_at
|
|
76
|
+
FROM schedules
|
|
77
|
+
WHERE archived_at IS NULL AND kind IN ('task','session')
|
|
78
|
+
AND run_at IS NULL AND project_id IS NOT NULL
|
|
79
|
+
ORDER BY created_at, id`
|
|
80
|
+
)
|
|
81
|
+
.all() as ScheduleRow[];
|
|
82
|
+
|
|
83
|
+
const insertRecipe = db.prepare(
|
|
84
|
+
`INSERT INTO recipes (id, workspace_id, project_id, name, kind, brief, model, view, created_by_session_id, updated_by_session_id, created_at)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, 'chat', ?, ?, ?)`
|
|
86
|
+
);
|
|
87
|
+
const insertRevision = db.prepare(
|
|
88
|
+
`INSERT INTO recipe_revisions (recipe_id, brief, settings, edited_by_session_id) VALUES (?, ?, ?, ?)`
|
|
89
|
+
);
|
|
90
|
+
const switchSchedule = db.prepare(
|
|
91
|
+
`UPDATE schedules SET kind = 'recipe', recipe_id = ?, migrated_from_kind = ?, updated_at = datetime('now') WHERE id = ?`
|
|
92
|
+
);
|
|
93
|
+
const runsOf = db.prepare(
|
|
94
|
+
`SELECT r.id, r.trigger, r.session_id, r.created_at,
|
|
95
|
+
${hasTaskStatus ? "s.task_status" : "NULL"} AS task_status, ${pr},
|
|
96
|
+
s.id IS NOT NULL AS session_exists
|
|
97
|
+
FROM schedule_runs r LEFT JOIN sessions s ON s.id = r.session_id
|
|
98
|
+
WHERE r.schedule_id = ? AND r.session_id IS NOT NULL
|
|
99
|
+
ORDER BY r.id`
|
|
100
|
+
);
|
|
101
|
+
const insertRun = db.prepare(
|
|
102
|
+
`INSERT INTO recipe_runs (recipe_id, seq, trigger, schedule_run_id, prompt, status, session_id, pr_number, pr_url, summary, started_at, created_at)
|
|
103
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
let movedRuns = 0;
|
|
107
|
+
const move = db.transaction(() => {
|
|
108
|
+
for (const s of schedules) {
|
|
109
|
+
const recipeId = randomUUID();
|
|
110
|
+
insertRecipe.run(
|
|
111
|
+
recipeId,
|
|
112
|
+
s.workspace_id,
|
|
113
|
+
s.project_id,
|
|
114
|
+
freeName(db, s.workspace_id, s.name),
|
|
115
|
+
s.kind,
|
|
116
|
+
s.prompt,
|
|
117
|
+
s.created_by_session_id,
|
|
118
|
+
s.created_by_session_id,
|
|
119
|
+
s.created_at
|
|
120
|
+
);
|
|
121
|
+
insertRevision.run(
|
|
122
|
+
recipeId,
|
|
123
|
+
s.prompt,
|
|
124
|
+
JSON.stringify({
|
|
125
|
+
kind: s.kind,
|
|
126
|
+
model: null,
|
|
127
|
+
view: "chat",
|
|
128
|
+
auto_done: true,
|
|
129
|
+
max_minutes: null,
|
|
130
|
+
}),
|
|
131
|
+
s.created_by_session_id
|
|
132
|
+
);
|
|
133
|
+
switchSchedule.run(recipeId, s.kind, s.id);
|
|
134
|
+
|
|
135
|
+
const runs = runsOf.all(s.id) as RunRow[];
|
|
136
|
+
runs.forEach((r, i) => {
|
|
137
|
+
// One live run per recipe: only the latest may still be running
|
|
138
|
+
// (settle judges it); an earlier one with no recorded end is
|
|
139
|
+
// closed as cancelled rather than guessed a result.
|
|
140
|
+
const final = r.task_status ? FINAL[r.task_status] : undefined;
|
|
141
|
+
const last = i === runs.length - 1;
|
|
142
|
+
const status = final ?? (last ? "running" : "cancelled");
|
|
143
|
+
insertRun.run(
|
|
144
|
+
recipeId,
|
|
145
|
+
i + 1,
|
|
146
|
+
TRIGGER[r.trigger] ?? "timer",
|
|
147
|
+
r.id,
|
|
148
|
+
s.prompt,
|
|
149
|
+
status,
|
|
150
|
+
r.session_id,
|
|
151
|
+
r.pr_number,
|
|
152
|
+
r.pr_url,
|
|
153
|
+
status === "cancelled"
|
|
154
|
+
? "Ran before recipes; its outcome wasn't recorded"
|
|
155
|
+
: null,
|
|
156
|
+
r.created_at,
|
|
157
|
+
r.created_at
|
|
158
|
+
);
|
|
159
|
+
movedRuns++;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
move();
|
|
164
|
+
return { schedules: schedules.length, runs: movedRuns };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Undo the move: each moved schedule gets its old kind (and the brief as it
|
|
168
|
+
// stands now) back, and the recipes the move made go, with their runs and
|
|
169
|
+
// revisions. The runs stay in schedule_runs, which the move never touched.
|
|
170
|
+
// Migration 81 stays recorded as applied, so a restart doesn't redo it.
|
|
171
|
+
export function revertSchedulesMove(db: Database.Database): {
|
|
172
|
+
schedules: number;
|
|
173
|
+
} {
|
|
174
|
+
const moved = db
|
|
175
|
+
.prepare(
|
|
176
|
+
`SELECT id, recipe_id, migrated_from_kind FROM schedules WHERE migrated_from_kind IS NOT NULL`
|
|
177
|
+
)
|
|
178
|
+
.all() as {
|
|
179
|
+
id: string;
|
|
180
|
+
recipe_id: string | null;
|
|
181
|
+
migrated_from_kind: string;
|
|
182
|
+
}[];
|
|
183
|
+
const recipe = db.prepare(
|
|
184
|
+
`SELECT brief, project_id FROM recipes WHERE id = ?`
|
|
185
|
+
);
|
|
186
|
+
const restore = db.prepare(
|
|
187
|
+
`UPDATE schedules SET kind = ?, recipe_id = NULL, migrated_from_kind = NULL,
|
|
188
|
+
prompt = COALESCE(?, prompt), project_id = COALESCE(?, project_id), updated_at = datetime('now')
|
|
189
|
+
WHERE id = ?`
|
|
190
|
+
);
|
|
191
|
+
const drop = (table: string) =>
|
|
192
|
+
db.prepare(`DELETE FROM ${table} WHERE recipe_id = ?`);
|
|
193
|
+
const [dropRuns, dropRevisions] = [
|
|
194
|
+
drop("recipe_runs"),
|
|
195
|
+
drop("recipe_revisions"),
|
|
196
|
+
];
|
|
197
|
+
const dropRecipe = db.prepare(`DELETE FROM recipes WHERE id = ?`);
|
|
198
|
+
|
|
199
|
+
db.transaction(() => {
|
|
200
|
+
for (const s of moved) {
|
|
201
|
+
const r = s.recipe_id
|
|
202
|
+
? (recipe.get(s.recipe_id) as
|
|
203
|
+
| { brief: string; project_id: string }
|
|
204
|
+
| undefined)
|
|
205
|
+
: undefined;
|
|
206
|
+
restore.run(
|
|
207
|
+
s.migrated_from_kind,
|
|
208
|
+
r?.brief ?? null,
|
|
209
|
+
r?.project_id ?? null,
|
|
210
|
+
s.id
|
|
211
|
+
);
|
|
212
|
+
if (s.recipe_id) {
|
|
213
|
+
dropRuns.run(s.recipe_id);
|
|
214
|
+
dropRevisions.run(s.recipe_id);
|
|
215
|
+
dropRecipe.run(s.recipe_id);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
219
|
+
return { schedules: moved.length };
|
|
220
|
+
}
|
package/lib/orchestration.ts
CHANGED
|
@@ -168,7 +168,7 @@ export async function spawnWorker(
|
|
|
168
168
|
|
|
169
169
|
// Create tmux session with the agent and banner
|
|
170
170
|
const agentCmd = `${provider.command} ${flagsStr}`;
|
|
171
|
-
const newSessionCmd = wrapWithBanner(agentCmd);
|
|
171
|
+
const newSessionCmd = wrapWithBanner(agentCmd, provider.preamble);
|
|
172
172
|
try {
|
|
173
173
|
await tmux(["set", "-g", "mouse", "on"]).catch(() => {});
|
|
174
174
|
// The last argument is the agent's command line, which tmux runs.
|
|
@@ -19,6 +19,7 @@ import { resolveAsks } from "./asks";
|
|
|
19
19
|
import { clearStaleRunning, getCheck } from "./checks";
|
|
20
20
|
import { gateTarget, type ExternalPR } from "./external-pr";
|
|
21
21
|
import { holdingEscalations } from "./gates";
|
|
22
|
+
import { reviewWanted } from "./held-review";
|
|
22
23
|
import { addNote } from "./notes";
|
|
23
24
|
import { commitTime } from "./repo";
|
|
24
25
|
import { review, reviewAt, type ReviewOpts } from "./review";
|
|
@@ -154,7 +155,10 @@ export async function reviewExternal(
|
|
|
154
155
|
): Promise<string> {
|
|
155
156
|
clearStaleRunning();
|
|
156
157
|
const held = holdingEscalations(ext.id);
|
|
157
|
-
if (
|
|
158
|
+
if (
|
|
159
|
+
held.length &&
|
|
160
|
+
!(ext.pr.head && reviewWanted(workspaceId, ext.id, ext.pr.head))
|
|
161
|
+
)
|
|
158
162
|
return `${ext.name} is with Saad (${held.map((h) => h.gate).join(", ")}); no review needed from you.`;
|
|
159
163
|
if (!ext.pr.head) throw new Error(`${ext.name}'s head commit is unknown`);
|
|
160
164
|
return reviewAt(
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// A held task is with Saad, so `review` normally has nothing to do. Except
|
|
2
|
+
// when the orchestrator has asked him to merge a commit: sign_off needs a
|
|
3
|
+
// finished review of exactly that commit before an approval of it merges,
|
|
4
|
+
// and a held task would otherwise never get one.
|
|
5
|
+
|
|
6
|
+
import { latestApproval } from "./ask-approvals";
|
|
7
|
+
import { openBySubject, workSubject } from "./asks";
|
|
8
|
+
|
|
9
|
+
export function reviewWanted(
|
|
10
|
+
workspaceId: string,
|
|
11
|
+
id: string,
|
|
12
|
+
head: string
|
|
13
|
+
): boolean {
|
|
14
|
+
const subject = workSubject(id);
|
|
15
|
+
if (latestApproval(workspaceId, subject)?.sha === head) return true;
|
|
16
|
+
const open = openBySubject(workspaceId, subject);
|
|
17
|
+
return open?.raised_by === "orchestrator" && open.sha === head;
|
|
18
|
+
}
|
|
@@ -24,6 +24,7 @@ import { changedFiles, fullDiff } from "./diff";
|
|
|
24
24
|
import { diffParts } from "./diff-parts";
|
|
25
25
|
import { countFailure, escalate } from "./escalate";
|
|
26
26
|
import { checkGate, holdingEscalations } from "./gates";
|
|
27
|
+
import { reviewWanted } from "./held-review";
|
|
27
28
|
import { mergeApprovalsOn } from "./merge-approvals";
|
|
28
29
|
import {
|
|
29
30
|
baseReviewSkill,
|
|
@@ -210,6 +211,8 @@ function countVerdicts(
|
|
|
210
211
|
target: ReviewTarget,
|
|
211
212
|
sha: string
|
|
212
213
|
): string {
|
|
214
|
+
// Held already: the verdict is only stored, for sign_off to read.
|
|
215
|
+
if (holdingEscalations(target.id).length) return "";
|
|
213
216
|
const escalated: string[] = [];
|
|
214
217
|
for (const gate of ["review", "scope"] as const) {
|
|
215
218
|
if (gate === "scope" && !target.task?.lh_card_id) continue;
|
|
@@ -308,11 +311,12 @@ export async function review(
|
|
|
308
311
|
if (task.task_status !== "running")
|
|
309
312
|
throw new Error(`${task.name} is already ${task.task_status}`);
|
|
310
313
|
const held = holdingEscalations(task.id);
|
|
311
|
-
|
|
314
|
+
const pr = held.length ? await prFor(task, true) : null;
|
|
315
|
+
if (held.length && !(pr?.head && reviewWanted(workspaceId, task.id, pr.head)))
|
|
312
316
|
return `${task.name} is with Saad (${held.map((h) => h.gate).join(", ")}); no review needed from you.`;
|
|
313
|
-
const
|
|
314
|
-
if (!
|
|
315
|
-
return reviewAt(workspaceId, taskReviewTarget(task,
|
|
317
|
+
const there = pr ?? (await prFor(task, true));
|
|
318
|
+
if (!there?.head) throw new Error(`${task.name} has no PR to review yet`);
|
|
319
|
+
return reviewAt(workspaceId, taskReviewTarget(task, there), there.head, opts);
|
|
316
320
|
}
|
|
317
321
|
|
|
318
322
|
export function taskReviewTarget(task: Session, pr: TaskPR): ReviewTarget {
|
|
@@ -16,6 +16,8 @@ export const PROVIDER_IDS = [
|
|
|
16
16
|
"pi",
|
|
17
17
|
"omp",
|
|
18
18
|
"copilot",
|
|
19
|
+
"kiro",
|
|
20
|
+
"amazonq",
|
|
19
21
|
"shell",
|
|
20
22
|
] as const;
|
|
21
23
|
|
|
@@ -53,6 +55,17 @@ export interface ProviderDefinition {
|
|
|
53
55
|
|
|
54
56
|
// Default arguments
|
|
55
57
|
defaultArgs?: string[]; // Always passed to CLI
|
|
58
|
+
|
|
59
|
+
// A subcommand that comes before every other argument (`kiro-cli chat`).
|
|
60
|
+
subcommand?: string[];
|
|
61
|
+
|
|
62
|
+
// How to sign in from a terminal: `check` exits 0 when signed in, `login`
|
|
63
|
+
// prints a link and a code to finish on another device. A session runs
|
|
64
|
+
// them first, so it never waits on a prompt nobody can see.
|
|
65
|
+
signIn?: { check: string[]; login: string[] };
|
|
66
|
+
|
|
67
|
+
// A superseded CLI, offered only where it is installed.
|
|
68
|
+
legacy?: boolean;
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
// The model value that means "whatever the agent itself defaults to".
|
|
@@ -63,6 +76,34 @@ export const AGENT_DEFAULT_MODEL = "default";
|
|
|
63
76
|
export const isAgentModelName = (v: unknown): v is string =>
|
|
64
77
|
typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,99}$/.test(v);
|
|
65
78
|
|
|
79
|
+
const KIRO: ProviderDefinition = {
|
|
80
|
+
id: "kiro",
|
|
81
|
+
name: "Kiro CLI",
|
|
82
|
+
description: "AWS's Kiro CLI (formerly Amazon Q Developer CLI)",
|
|
83
|
+
cli: "kiro-cli",
|
|
84
|
+
configDir: "~/.kiro",
|
|
85
|
+
subcommand: ["chat"],
|
|
86
|
+
autoApproveFlag: "--trust-all-tools",
|
|
87
|
+
// `--resume-id` exists, but where Kiro keeps its sessions isn't something
|
|
88
|
+
// we read yet (lib/providers/resume-ids.ts), so no id ever reaches it.
|
|
89
|
+
supportsResume: false,
|
|
90
|
+
supportsFork: false,
|
|
91
|
+
modelFlag: "--model",
|
|
92
|
+
initialPromptFlag: "", // Positional argument
|
|
93
|
+
signIn: { check: ["whoami"], login: ["login", "--use-device-flow"] },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// The same CLI under its old name, with fewer flags.
|
|
97
|
+
const AMAZON_Q: ProviderDefinition = {
|
|
98
|
+
...KIRO,
|
|
99
|
+
id: "amazonq",
|
|
100
|
+
name: "Amazon Q CLI (legacy)",
|
|
101
|
+
description: "Amazon Q Developer CLI, replaced by Kiro CLI",
|
|
102
|
+
cli: "q",
|
|
103
|
+
configDir: "~/.aws/amazonq",
|
|
104
|
+
legacy: true,
|
|
105
|
+
};
|
|
106
|
+
|
|
66
107
|
/**
|
|
67
108
|
* Provider Registry
|
|
68
109
|
* All supported agent providers with their configurations
|
|
@@ -211,6 +252,8 @@ export const PROVIDERS: ProviderDefinition[] = [
|
|
|
211
252
|
modelFlag: "--model",
|
|
212
253
|
initialPromptFlag: "-i",
|
|
213
254
|
},
|
|
255
|
+
KIRO,
|
|
256
|
+
AMAZON_Q,
|
|
214
257
|
{
|
|
215
258
|
id: "shell",
|
|
216
259
|
name: "Terminal",
|
package/lib/providers.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* into a command line.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { SIGN_IN_END, SIGN_IN_START } from "./agents/signin-screen";
|
|
9
10
|
import {
|
|
10
11
|
AGENT_DEFAULT_MODEL,
|
|
11
12
|
type ProviderDefinition,
|
|
@@ -25,6 +26,8 @@ export interface AgentProvider {
|
|
|
25
26
|
supportsResume: boolean;
|
|
26
27
|
supportsFork: boolean;
|
|
27
28
|
configDir: string;
|
|
29
|
+
// Shell that signs in first when the CLI isn't, or "".
|
|
30
|
+
preamble: string;
|
|
28
31
|
// The CLI's arguments, each already quoted for the shell.
|
|
29
32
|
buildFlags(options: BuildFlagsOptions): string[];
|
|
30
33
|
}
|
|
@@ -44,7 +47,7 @@ export function buildArgs(
|
|
|
44
47
|
def: ProviderDefinition,
|
|
45
48
|
options: BuildFlagsOptions
|
|
46
49
|
): string[] {
|
|
47
|
-
const args: string[] = [];
|
|
50
|
+
const args: string[] = [...(def.subcommand ?? [])];
|
|
48
51
|
if (options.sessionId && def.resumeArgs) {
|
|
49
52
|
args.push(...def.resumeArgs(options.sessionId));
|
|
50
53
|
} else if (options.parentSessionId && def.forkArgs) {
|
|
@@ -69,6 +72,20 @@ const SAFE_ARG = /^[\w@%+=:,./-]+$/;
|
|
|
69
72
|
const quoteArg = (arg: string) =>
|
|
70
73
|
SAFE_ARG.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
71
74
|
|
|
75
|
+
// Signs in when the CLI isn't, between two marker lines the screen reader
|
|
76
|
+
// looks for (lib/agents/signin-screen.ts), and stops if that fails, so the
|
|
77
|
+
// pane shows why instead of a CLI that waits.
|
|
78
|
+
function signInPreamble(def: ProviderDefinition): string {
|
|
79
|
+
if (!def.signIn) return "";
|
|
80
|
+
const cli = quoteArg(def.cli);
|
|
81
|
+
const run = (args: string[]) => [cli, ...args.map(quoteArg)].join(" ");
|
|
82
|
+
return (
|
|
83
|
+
`{ ${run(def.signIn.check)} >/dev/null 2>&1 || ` +
|
|
84
|
+
`{ echo '${SIGN_IN_START}'; ${run(def.signIn.login)}; s=$?; ` +
|
|
85
|
+
`echo '${SIGN_IN_END}'; [ $s -eq 0 ]; }; } && `
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
function toProvider(def: ProviderDefinition): AgentProvider {
|
|
73
90
|
return {
|
|
74
91
|
id: def.id,
|
|
@@ -78,6 +95,7 @@ function toProvider(def: ProviderDefinition): AgentProvider {
|
|
|
78
95
|
supportsResume: def.supportsResume,
|
|
79
96
|
supportsFork: def.supportsFork,
|
|
80
97
|
configDir: def.configDir,
|
|
98
|
+
preamble: signInPreamble(def),
|
|
81
99
|
buildFlags: (options) => buildArgs(def, options).map(quoteArg),
|
|
82
100
|
};
|
|
83
101
|
}
|
package/lib/start-options.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { AGENT_OPTIONS } from "./agent-options";
|
|
9
|
+
import { getProviderDefinition } from "./providers/registry";
|
|
9
10
|
import { probeAgents } from "./agents/probe";
|
|
10
11
|
import { gitCheck } from "./git";
|
|
11
12
|
import { listHosts } from "./hosts";
|
|
@@ -64,7 +65,10 @@ export async function startOptions(
|
|
|
64
65
|
hostId: project.host_id || "local",
|
|
65
66
|
}
|
|
66
67
|
: null,
|
|
67
|
-
agents: AGENT_OPTIONS.
|
|
68
|
+
agents: AGENT_OPTIONS.filter(
|
|
69
|
+
(a) =>
|
|
70
|
+
!getProviderDefinition(a.value)?.legacy || probes?.[a.value]?.installed
|
|
71
|
+
).map((a) => ({
|
|
68
72
|
value: a.value,
|
|
69
73
|
label: a.label,
|
|
70
74
|
installed: probes?.[a.value]?.installed ?? null,
|
package/lib/status/collect.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
findQuestion,
|
|
10
10
|
plainText,
|
|
11
11
|
readInputBox,
|
|
12
|
+
screenText,
|
|
12
13
|
statusDetector,
|
|
13
14
|
sessionKey,
|
|
14
15
|
type SessionStatus,
|
|
@@ -16,9 +17,12 @@ import {
|
|
|
16
17
|
import type { AgentType } from "../providers";
|
|
17
18
|
import {
|
|
18
19
|
getManagedSessionPattern,
|
|
20
|
+
getProviderDefinition,
|
|
19
21
|
getProviderIdFromSessionName,
|
|
20
22
|
getSessionIdFromName,
|
|
21
23
|
} from "../providers/registry";
|
|
24
|
+
import { findSignIn } from "../agents/signin-screen";
|
|
25
|
+
import { noteWaiting } from "../notify/push/waiting";
|
|
22
26
|
import { getDb } from "../db";
|
|
23
27
|
import { beginPass, chatMemo } from "./incremental";
|
|
24
28
|
import { chatCompacting, chatState } from "../chat/runner";
|
|
@@ -72,6 +76,8 @@ export interface SessionStatusResponse {
|
|
|
72
76
|
prStage?: PrStage;
|
|
73
77
|
// A program's own message (OSC 7501): untrusted, plain text only.
|
|
74
78
|
detail?: string | null;
|
|
79
|
+
// The link and code a sign-in is waiting on (lib/agents/signin-screen).
|
|
80
|
+
signIn?: { url: string | null; code: string | null } | null;
|
|
75
81
|
progress?: number | null;
|
|
76
82
|
}
|
|
77
83
|
|
|
@@ -244,6 +250,9 @@ function getAgentTypeFromSessionName(sessionName: string): AgentType {
|
|
|
244
250
|
return getProviderIdFromSessionName(sessionName) || "claude";
|
|
245
251
|
}
|
|
246
252
|
|
|
253
|
+
// Sessions whose sign-in has started the wait for you, until it is over.
|
|
254
|
+
const signingIn = new Set<string>();
|
|
255
|
+
|
|
247
256
|
async function collect(full = false): Promise<StatusSnapshot> {
|
|
248
257
|
const sessions = await getTmuxSessions();
|
|
249
258
|
|
|
@@ -308,6 +317,10 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
308
317
|
hostId,
|
|
309
318
|
});
|
|
310
319
|
const id = pane.id;
|
|
320
|
+
// A CLI that signs in with a link and a code shows them while it works.
|
|
321
|
+
const signIn = getProviderDefinition(agentType)?.signIn
|
|
322
|
+
? findSignIn(screenText(screen))
|
|
323
|
+
: null;
|
|
311
324
|
|
|
312
325
|
return {
|
|
313
326
|
sessionName,
|
|
@@ -316,6 +329,7 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
316
329
|
status,
|
|
317
330
|
program,
|
|
318
331
|
screenNeed,
|
|
332
|
+
signIn,
|
|
319
333
|
claudeSessionId,
|
|
320
334
|
lastLine: lastLineOf(screen),
|
|
321
335
|
agentType,
|
|
@@ -323,6 +337,13 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
323
337
|
});
|
|
324
338
|
|
|
325
339
|
const results = await Promise.all(sessionPromises);
|
|
340
|
+
// A session killed mid-login can't clear its own wait.
|
|
341
|
+
const alive = new Set(results.map((r) => r.id));
|
|
342
|
+
for (const id of signingIn)
|
|
343
|
+
if (!alive.has(id)) {
|
|
344
|
+
signingIn.delete(id);
|
|
345
|
+
noteWaiting(id, false);
|
|
346
|
+
}
|
|
326
347
|
heuristicBusy = results.some((r) => r.status === "running");
|
|
327
348
|
|
|
328
349
|
for (const {
|
|
@@ -332,6 +353,7 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
332
353
|
status,
|
|
333
354
|
program,
|
|
334
355
|
screenNeed,
|
|
356
|
+
signIn,
|
|
335
357
|
claudeSessionId,
|
|
336
358
|
lastLine,
|
|
337
359
|
agentType,
|
|
@@ -344,6 +366,20 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
344
366
|
agentType,
|
|
345
367
|
title: statusDetector.titleFor(sessionName, hostId),
|
|
346
368
|
};
|
|
369
|
+
// Waiting on a sign-in is waiting on you, buzzed after the usual minute.
|
|
370
|
+
if (signIn) {
|
|
371
|
+
if (!signingIn.has(id)) noteWaiting(id, true);
|
|
372
|
+
signingIn.add(id);
|
|
373
|
+
statusMap[id] = {
|
|
374
|
+
...shared,
|
|
375
|
+
status: "waiting",
|
|
376
|
+
need: "signin",
|
|
377
|
+
unread: false,
|
|
378
|
+
signIn: { url: signIn.url, code: signIn.code },
|
|
379
|
+
};
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (signingIn.delete(id)) noteWaiting(id, false);
|
|
347
383
|
// A question on screen, or a message typed and never sent: it needs you
|
|
348
384
|
// whether or not you've seen it, like a blocked program.
|
|
349
385
|
const needKey = screenNeed ? `${screenNeed.need}:${screenNeed.detail}` : "";
|
package/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undoes migration 81: schedules that became recipes get their old kind
|
|
3
|
+
* (task or session) and brief back, and the recipes the move made are
|
|
4
|
+
* removed with their runs. Stop AgentOS first; it reads the database too.
|
|
5
|
+
*
|
|
6
|
+
* npx tsx scripts/revert-schedules-move.ts --db <path to agent-os.db>
|
|
7
|
+
*
|
|
8
|
+
* Migration 81 stays recorded as applied, so a restart won't redo the move.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import Database from "better-sqlite3";
|
|
12
|
+
import { revertSchedulesMove } from "../lib/db/schedules-move";
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const dbPath = args[args.indexOf("--db") + 1];
|
|
16
|
+
if (!args.includes("--db") || !dbPath || dbPath.startsWith("--")) {
|
|
17
|
+
console.error("usage: revert-schedules-move.ts --db <path>");
|
|
18
|
+
process.exit(2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const db = new Database(dbPath, { fileMustExist: true, timeout: 10000 });
|
|
22
|
+
const { schedules } = revertSchedulesMove(db);
|
|
23
|
+
console.log(`Restored ${schedules} schedule(s) to their old kind.`);
|
|
24
|
+
db.close();
|
package/scripts/setup.sh
CHANGED
|
@@ -29,7 +29,7 @@ echo "tmux: $(tmux -V)"
|
|
|
29
29
|
|
|
30
30
|
# Check AI coding CLI
|
|
31
31
|
AI_CLI_FOUND=""
|
|
32
|
-
for cli in claude codex opencode kilo gemini aider cursor-agent amp pi omp copilot; do
|
|
32
|
+
for cli in claude codex opencode kilo gemini aider cursor-agent amp pi omp copilot kiro-cli q; do
|
|
33
33
|
if command -v "$cli" &> /dev/null; then
|
|
34
34
|
AI_CLI_FOUND="$AI_CLI_FOUND $cli"
|
|
35
35
|
fi
|
|
@@ -37,7 +37,7 @@ done
|
|
|
37
37
|
|
|
38
38
|
if [ -z "$AI_CLI_FOUND" ]; then
|
|
39
39
|
echo "Error: no supported AI coding CLI is installed"
|
|
40
|
-
echo "Install one of: Claude Code, Codex, OpenCode, Kilo Code CLI, Gemini CLI, Aider, Cursor CLI, Amp, Pi,
|
|
40
|
+
echo "Install one of: Claude Code, Codex, OpenCode, Kilo Code CLI, Gemini CLI, Aider, Cursor CLI, Amp, Pi, Oh My Pi, or Kiro CLI"
|
|
41
41
|
exit 1
|
|
42
42
|
fi
|
|
43
43
|
echo "AI CLI(s):$AI_CLI_FOUND"
|