@saadnvd1/agent-os 0.3.535 → 0.3.538

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -1578,8 +1590,11 @@ approved`). An approval covers that one item only: a held task's approval
1578
1590
  pass, by giving `ask_saad` the task and its head sha: the ask is pinned to
1579
1591
  that commit, opens with what AgentOS knows about it (files changed,
1580
1592
  sensitive files, the review's verdict) and links the PR, and your approval
1581
- lets `sign_off` merge it once, at that commit, with a code review of it in
1582
- the PR body. It never rewrites an ask the gates raised.
1593
+ lets `sign_off` merge it once, at that commit. It never rewrites an ask the
1594
+ gates raised. An approved merge (either kind, and an armed task's) needs no
1595
+ review or other gate of that commit: only an AI-attributed PR body, failing
1596
+ CI (running CI waits), a head that moved off the commit, and on the
1597
+ orchestrator's own ask a stack parent not merged yet, still stop it.
1583
1598
  An ask closes itself when its task is merged or dropped, or the brakes
1584
1599
  lift. At most 10 are open per workspace, titles that say the same thing
1585
1600
  fold into one, a declined subject isn't asked again for 6 hours, and a
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
- <Terminal
327
- ref={getTerminalRef(tab.id)}
328
- onConnected={getTerminalConnectedHandler(tab)}
329
- onBeforeUnmount={(scrollState) => {
330
- sessionRegistry.saveTerminalState(paneId, tab.id, {
331
- scrollTop: scrollState.scrollTop,
332
- scrollHeight: 0,
333
- lastActivity: Date.now(),
334
- cursorY: scrollState.cursorY,
335
- });
336
- }}
337
- initialScrollState={
338
- savedState
339
- ? {
340
- scrollTop: savedState.scrollTop,
341
- cursorY: savedState.cursorY,
342
- baseY: 0,
343
- }
344
- : undefined
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
 
@@ -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((p) => p.cli);
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
+ }
@@ -0,0 +1,10 @@
1
+ Logging in to Kiro
2
+
3
+ Confirm the code in your browser:
4
+
5
+ ABCD-EFGH
6
+
7
+ Open this URL to continue:
8
+ https://view.awsapps.com/start/#/device?user_code=ABCD-EFGH
9
+
10
+ Waiting for authorization...
@@ -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) =>
@@ -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: async (cli, env) => {
98
- try {
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(agentCommand: string): string {
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 });
@@ -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.
@@ -8,7 +8,8 @@
8
8
  * The orchestrator hears one event: merged, or a failure. A failure is
9
9
  * counted against the task once per head, as sign_off would count it, and
10
10
  * reported once; the task stays armed for its fix. One that goes to Saad
11
- * disarms it, since only he merges it from then on. The flag is in the
11
+ * disarms it, since only he merges it from then on, or approves its head
12
+ * for it to merge on that approval. The flag is in the
12
13
  * database, so it survives a restart.
13
14
  */
14
15
 
@@ -85,17 +86,19 @@ export function arm(workspaceId: string, ref: string): string {
85
86
  }
86
87
 
87
88
  // Arms a task, or says why it can't be: only a running task, in a
88
- // workspace whose orchestrator hears how it went, that isn't with Saad.
89
+ // workspace whose orchestrator hears how it went, that isn't with Saad
90
+ // (or that he approved a commit of: it merges on that, at that commit only).
89
91
  export function armTask(task: Session): void {
90
92
  if (task.task_status !== "running")
91
93
  throw new Error(`${task.name} is already ${task.task_status}`);
92
- const held = holdingEscalations(task.id);
94
+ const w = armingWorkspace(task);
95
+ const approved = !!w && !!latestApproval(w, workSubject(task.id))?.sha;
96
+ const held = approved ? [] : holdingEscalations(task.id);
93
97
  if (held.length)
94
98
  throw new Error(
95
99
  `${task.name} is with Saad (${held.map((h) => h.gate).join(", ")}); only he merges it`
96
100
  );
97
- const w = armingWorkspace(task);
98
- if (w && openBySubject(w, workSubject(task.id)))
101
+ if (w && !approved && openBySubject(w, workSubject(task.id)))
99
102
  throw new Error(
100
103
  `${task.name} has an open ask with Saad; it merges on his answer`
101
104
  );
@@ -209,9 +212,16 @@ async function mergeOne(
209
212
  return;
210
213
  }
211
214
  // With Saad, on a gate or on a question about it: he decides, so it
212
- // doesn't merge by itself meanwhile.
213
- const held = holdingEscalations(task.id).map((h) => h.gate);
214
- if (!held.length && openBySubject(workspaceId, workSubject(task.id)))
215
+ // doesn't merge by itself meanwhile, unless he approved this head.
216
+ const approved =
217
+ !!pr.head &&
218
+ latestApproval(workspaceId, workSubject(task.id))?.sha === pr.head;
219
+ const held = approved ? [] : holdingEscalations(task.id).map((h) => h.gate);
220
+ if (
221
+ !approved &&
222
+ !held.length &&
223
+ openBySubject(workspaceId, workSubject(task.id))
224
+ )
215
225
  held.push("an open ask");
216
226
  if (held.length) {
217
227
  setArmed(task.id, false);
@@ -122,7 +122,7 @@ export async function askSaad(
122
122
  }
123
123
  const { ask, created } = raised;
124
124
  const once = merge
125
- ? ` If he approves, sign_off merges it once, at ${merge.sha.slice(0, 7)} only.`
125
+ ? ` If he approves, sign_off merges it once, at ${merge.sha.slice(0, 7)} only, without waiting on a review.`
126
126
  : "";
127
127
  if (!created)
128
128
  return `Already on Saad's list as ask ${ask.id}; updated it.${once} Carry on with everything else.`;
@@ -86,11 +86,11 @@ ${brakesEnabled() ? BRAKES_ON : BRAKES_OFF}
86
86
  - Credentials and account security.
87
87
  - Choosing what gets built when it's a product call he hasn't made.
88
88
 
89
- When one of these comes up, park it with \`ask_saad\` (kind: the hard line it crosses) and carry on with everything else. Use it too for a decision that's his (kind: decision). Escalated gates and brakes become asks on their own; don't ask again for those. To ask whether to merge a PR the gates won't pass, give \`ask_saad\` the task and the PR's head sha: if he approves, \`sign_off\` merges it once, at that commit only.
89
+ When one of these comes up, park it with \`ask_saad\` (kind: the hard line it crosses) and carry on with everything else. Use it too for a decision that's his (kind: decision). Escalated gates and brakes become asks on their own; don't ask again for those. To ask whether to merge a PR the gates won't pass, give \`ask_saad\` the task and the PR's head sha: if he approves, \`sign_off\` merges it once, at that commit only, without the review gates.
90
90
 
91
91
  ## Asks and pause
92
92
 
93
- Saad answers an ask on his list, and the answer reaches you as an event: \`ask "<title>": approved\`, \`declined\`, or \`reply: <text>\`. Act on it. An approval covers that one item only, never standing permission: the next item like it is a new ask. Approving a held task lets \`sign_off\` merge it once, at the commit he approved; approving a brake lets one start through, once.
93
+ Saad answers an ask on his list, and the answer reaches you as an event: \`ask "<title>": approved\`, \`declined\`, or \`reply: <text>\`. Act on it. An approval covers that one item only, never standing permission: the next item like it is a new ask. Approving a merge (a held task's or your own ask's) lets \`sign_off\`, or an armed task, merge it once at the commit he approved, with no review, scope or other gate in the way: only an AI-attributed PR body, failing CI (running CI is a wait), an unmerged stack parent on your own ask, or a head that moved off that commit still stop it; approving a brake lets one start through, once.
94
94
 
95
95
  Anything you need from Saad in chat (a question, a choice, a confirmation) goes through \`question\` in the same turn you ask it, and you still write the question plainly in your reply. It stays pinned on his Needs-you strip until it is resolved: when he answers (in chat, or with the strip's Reply) or it stops mattering, call \`resolve_question\`. Don't leave one open once he has answered. \`ask_saad\` is for hard lines and calls that need Approve or Decline; \`question\` is for everything conversational. Don't open a question for something that is already an ask. Updates AgentOS sends you fold into one-line rows in his chat, so a question buried in a long reply to an event is easy to miss: that is what \`question\` is for.
96
96
 
@@ -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 (held.length)
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(
@@ -65,7 +65,18 @@ export interface GateInput {
65
65
 
66
66
  const short = (sha: string) => sha.slice(0, 7);
67
67
 
68
- function ciGate(i: GateInput): GateOutcome {
68
+ export function ciGate(
69
+ i: Pick<
70
+ GateInput,
71
+ | "sha"
72
+ | "checks"
73
+ | "failing"
74
+ | "settleIn"
75
+ | "mergeable"
76
+ | "baseName"
77
+ | "repoCI"
78
+ >
79
+ ): GateOutcome {
69
80
  const gate = "ci";
70
81
  const at = short(i.sha);
71
82
  if (i.checks === "pending")
@@ -88,7 +99,7 @@ function ciGate(i: GateInput): GateOutcome {
88
99
 
89
100
  // A head with no checks. Only a repository with no CI at all has nothing to
90
101
  // gate on; in one with CI the head is not yet, whatever the reason.
91
- function noChecks(i: GateInput, at: string): GateOutcome {
102
+ function noChecks(i: Parameters<typeof ciGate>[0], at: string): GateOutcome {
92
103
  const gate = "ci";
93
104
  const wait = (reason: string): GateOutcome => ({
94
105
  gate,
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  // A task that's with Saad merges only on his approval of its exact head
2
- // commit, once. A new commit since he approved goes back to him.
2
+ // commit, once (signoff.ts). A new commit since he approved goes back to him.
3
3
 
4
4
  import { AskRefused, raiseAsk, workSubject } from "./asks";
5
5
  import { latestApproval } from "./ask-approvals";
@@ -17,10 +17,8 @@ export function heldVerdict(
17
17
  pr: number,
18
18
  moved?: string
19
19
  ): Verdict {
20
- // One the orchestrator raised was judged before this (signoff.ts).
20
+ // An approval of this head was judged before this (signoff.ts).
21
21
  const approval = latestApproval(workspaceId, workSubject(task.id));
22
- if (approval?.sha === sha)
23
- return { ok: true, sha, pr, approval: approval.id };
24
22
  if (approval) {
25
23
  const why = `Saad approved ${approval.sha ? short(approval.sha) : "an earlier commit"}, but PR #${pr}'s head is now ${short(sha)}${moved ? ` and ${moved}` : ""}`;
26
24
  try {
@@ -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
- if (held.length)
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 pr = await prFor(task, true);
314
- if (!pr?.head) throw new Error(`${task.name} has no PR to review yet`);
315
- return reviewAt(workspaceId, taskReviewTarget(task, pr), pr.head, opts);
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 {
@@ -25,7 +25,12 @@ import {
25
25
  sensitiveFiles,
26
26
  } from "./diff";
27
27
  import { countFailure, escalate } from "./escalate";
28
- import { evaluateGates, holdingEscalations, type GateInput } from "./gates";
28
+ import {
29
+ ciGate,
30
+ evaluateGates,
31
+ holdingEscalations,
32
+ type GateInput,
33
+ } from "./gates";
29
34
  import { addNote } from "./notes";
30
35
  import { latestApproval, refundApproval, spendApproval } from "./ask-approvals";
31
36
  import { workSubject } from "./asks";
@@ -96,7 +101,11 @@ export async function judge(
96
101
  restackedOf(task)?.to === sha
97
102
  )
98
103
  : null;
99
- if (quick?.wait)
104
+ // Saad's approval of this head stands in for that review (gateVerdict).
105
+ if (
106
+ quick?.wait &&
107
+ latestApproval(workspaceId, workSubject(task.id))?.sha !== sha
108
+ )
100
109
  return {
101
110
  ok: false,
102
111
  wait: true,
@@ -164,13 +173,12 @@ export async function gateVerdict(
164
173
  });
165
174
  const { pr, sha, repo } = work;
166
175
  const held = holdingEscalations(work.id);
167
- // Saad approved merging this exact commit. On an ask the orchestrator
168
- // raised (ask_saad with its task and sha), held or not, it merges once
169
- // whatever the gates say, but only once an independent review of that
170
- // commit finished (its findings are his to override, not that it ran) and
171
- // its stack parent merged: the orchestrator wrote that ask, and no answer
172
- // to it stands in for those. An approval of the gates' own ask on a held
173
- // task merges as it always has (heldVerdict).
176
+ // Saad approved merging this exact commit: held or not, it merges once,
177
+ // without a review or any other gate approval stands in for. Approval
178
+ // doesn't cover what's left: an AI-attributed body, failing CI (running
179
+ // CI is a wait), and, on an ask the orchestrator raised or a task the
180
+ // gates don't hold, a stack parent not merged yet. On a held task, an
181
+ // approval of an earlier commit goes back to him (heldVerdict).
174
182
  let approval = latestApproval(workspaceId, workSubject(work.id));
175
183
  // The head moved since he approved: his approval follows it when the PR's
176
184
  // own patch is unchanged (a rebase), else the re-ask says what changed.
@@ -185,19 +193,19 @@ export async function gateVerdict(
185
193
  if (carried.approval) approval = carried.approval;
186
194
  else moved = carried.note;
187
195
  }
188
- if (
189
- approval?.sha === sha &&
190
- (approval.raised_by === "orchestrator" || !held.length)
191
- ) {
196
+ if (approval?.sha === sha) {
192
197
  const i = await inputs();
193
- const refusal =
194
- i.codeReview.refusal ??
195
- (i.codeReview.ran ? null : i.codeReview.wait) ??
196
- i.stackRefusal;
197
- if (refusal)
198
- return no(
199
- `${work.name} (PR #${pr.number} at ${short(sha)}): Saad approved this commit, but ${refusal}`
200
- );
198
+ const approved = `${work.name} (PR #${pr.number} at ${short(sha)}): Saad approved this commit, but`;
199
+ const stack =
200
+ approval.raised_by === "orchestrator" || !held.length
201
+ ? i.stackRefusal
202
+ : null;
203
+ const refusal = i.codeReview.refusal ?? stack;
204
+ if (refusal) return no(`${approved} ${refusal}`);
205
+ const ci = ciGate({ sha, ...i, repoCI: await repoCIFor(repo, work, i) });
206
+ if (ci.state === "fail") return no(`${approved} ${ci.reason}`);
207
+ if (ci.state === "wait") return no(`${approved} ${ci.reason}`, true);
208
+ // "escalate" is a repository with no CI: Saad is who that goes to.
201
209
  return { ok: true, sha, pr: pr.number, approval: approval.id };
202
210
  }
203
211
  if (held.length)
@@ -223,14 +231,7 @@ export async function gateVerdict(
223
231
 
224
232
  const ruled = ruleBreaks(files, await addedLines(repo, base, sha));
225
233
  const live = await inputs();
226
- // Asked only when it can change the answer: a head with no checks that
227
- // isn't settling or conflicting.
228
- const repoCI =
229
- live.checks === "none" &&
230
- live.settleIn === 0 &&
231
- live.mergeable !== "CONFLICTING"
232
- ? await repoHasCI(repo, base)
233
- : undefined;
234
+ const repoCI = await repoCIFor(repo, base, live);
234
235
  const outcomes = evaluateGates({ sha, ruleBreaks: ruled, ...live, repoCI });
235
236
  const head = `${work.name} (PR #${pr.number} at ${short(sha)}) can't merge:`;
236
237
  const toSaad = outcomes.find((o) => o.state === "escalate");
@@ -253,6 +254,25 @@ export async function gateVerdict(
253
254
  return no(`${head}\n${lines.join("\n")}`, !failed.length);
254
255
  }
255
256
 
257
+ // Asked only when it can change the answer: a head with no checks that
258
+ // isn't settling or conflicting.
259
+ async function repoCIFor(
260
+ repo: string,
261
+ base: string | GateWork,
262
+ i: Pick<GateInput, "checks" | "settleIn" | "mergeable">
263
+ ) {
264
+ if (i.checks !== "none" || i.settleIn !== 0 || i.mergeable === "CONFLICTING")
265
+ return undefined;
266
+ if (typeof base !== "string") {
267
+ try {
268
+ base = await fetchRefs(repo, base.refs);
269
+ } catch {
270
+ return undefined;
271
+ }
272
+ }
273
+ return repoHasCI(repo, base);
274
+ }
275
+
256
276
  export async function signOff(
257
277
  workspaceId: string,
258
278
  ref: string
@@ -116,7 +116,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
116
116
  resolve_question:
117
117
  "Clear an open question once Saad has answered it in chat, or it no longer matters.",
118
118
  ask_saad:
119
- "Park an item on Saad's asks list (a decision, or anything crossing a hard line) and carry on; it never waits. One open ask per title. His answer reaches you as an event. To ask whether to merge a PR the gates refuse, give task and sha (its head): his approval lets sign_off merge it once, at that commit.",
119
+ "Park an item on Saad's asks list (a decision, or anything crossing a hard line) and carry on; it never waits. One open ask per title. His answer reaches you as an event. To ask whether to merge a PR the gates refuse, give task and sha (its head): his approval lets sign_off merge it once, at that commit, without a review (an AI-attributed body or failing CI still refuses).",
120
120
  };
121
121
 
122
122
  export function orchestratorTools(
@@ -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
  }
@@ -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.map((a) => ({
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,
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.535",
3
+ "version": "0.3.538",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
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, or Oh My 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"