agentus 0.0.0-stage → 0.2.0

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.
Files changed (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +476 -2
  4. package/README.zh-CN.md +396 -0
  5. package/bin/agentus.mjs +147 -0
  6. package/docs/android-notify-contract.md +658 -0
  7. package/docs/dev/01-dev-loop.md +186 -0
  8. package/docs/dev/02-verify.md +237 -0
  9. package/docs/dev/03-release.md +218 -0
  10. package/docs/dev/04-recover.md +199 -0
  11. package/docs/dev/05-agent-driven.md +114 -0
  12. package/docs/dev/README.md +65 -0
  13. package/docs/installation.md +366 -0
  14. package/docs/installation.zh-CN.md +350 -0
  15. package/docs/refs/aionui-acp-rules.md +46 -0
  16. package/docs/refs/hermes-studio-ui-ideas.md +191 -0
  17. package/docs/transcript-folding.md +139 -0
  18. package/package.json +76 -5
  19. package/packages/server/mcp/plan-server.mjs +217 -0
  20. package/packages/server/mock/agent.mjs +693 -0
  21. package/packages/server/package.json +22 -0
  22. package/packages/server/src/acp/backends.ts +196 -0
  23. package/packages/server/src/acp/errors.ts +46 -0
  24. package/packages/server/src/acp/registry.ts +579 -0
  25. package/packages/server/src/acp/session-manager.ts +1868 -0
  26. package/packages/server/src/auth.ts +688 -0
  27. package/packages/server/src/dashscope.ts +235 -0
  28. package/packages/server/src/fs.ts +379 -0
  29. package/packages/server/src/hotwords.ts +132 -0
  30. package/packages/server/src/index.ts +1486 -0
  31. package/packages/server/src/notify/center.ts +994 -0
  32. package/packages/server/src/notify/types.ts +222 -0
  33. package/packages/server/src/plan/plan.ts +126 -0
  34. package/packages/server/src/pty-helper.py +135 -0
  35. package/packages/server/src/settings.ts +469 -0
  36. package/packages/server/src/store/export.ts +209 -0
  37. package/packages/server/src/store/store.ts +1238 -0
  38. package/packages/server/src/term.ts +201 -0
  39. package/packages/server/src/title.ts +109 -0
  40. package/packages/server/src/voice.ts +251 -0
  41. package/packages/shared/package.json +14 -0
  42. package/packages/shared/src/index.ts +271 -0
  43. package/packages/web/dist/assets/index-D667PX2c.css +32 -0
  44. package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
  45. package/packages/web/dist/coding-agents/hermes.png +0 -0
  46. package/packages/web/dist/coding-agents/qoder.svg +5 -0
  47. package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
  48. package/packages/web/dist/icons/agentus-icon.svg +301 -0
  49. package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
  50. package/packages/web/dist/icons/agentus-mark.svg +301 -0
  51. package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
  52. package/packages/web/dist/icons/favicon.ico +0 -0
  53. package/packages/web/dist/icons/icon-192.png +0 -0
  54. package/packages/web/dist/icons/icon-512.png +0 -0
  55. package/packages/web/dist/icons/icon-maskable-512.png +0 -0
  56. package/packages/web/dist/index.html +30 -0
  57. package/packages/web/dist/manifest.webmanifest +17 -0
  58. package/packages/web/dist/sw.js +61 -0
  59. package/scripts/make-cert.sh +101 -0
@@ -0,0 +1,222 @@
1
+ // Notify contract v0.1 — the wire types shared by the Agentus server and the
2
+ // Android companion app. See docs/android-notify-contract.md at the workspace root
3
+ // (tasks/20261001-agentus/android-notify-contract.md) for the rationale.
4
+ //
5
+ // Rule of the contract: this file only ever GROWS. Adding a field is compatible;
6
+ // changing the meaning of an existing one is not. A device that does not know a
7
+ // field ignores it, and the server decides what to send from `capabilities`,
8
+ // never from a version number.
9
+
10
+ export type NotifyPlatform = "android" | "ios" | "web";
11
+
12
+ /** Device -> server handshake, sent once at pairing. */
13
+ export interface DeviceCapabilities {
14
+ platform: NotifyPlatform;
15
+ /** Android API level (36+ = Live Updates are possible). */
16
+ sdkInt: number;
17
+ appVersion: string;
18
+ /** Highest contract schema the app understands. */
19
+ schema: number;
20
+ /** Feature switches: live_update | progress | actions | remote_input | icon_url | channels | deeplink */
21
+ capabilities: string[];
22
+ }
23
+
24
+ export interface NotifyDevice extends DeviceCapabilities {
25
+ deviceId: string;
26
+ name: string;
27
+ /** Device bearer token (issued at pairing; stored in the app's Keystore). */
28
+ token: string;
29
+ createdAt: number;
30
+ lastSeenAt: number | null;
31
+ /** Highest activity seq this device has acknowledged (replay anchor). */
32
+ cursor: number;
33
+ /**
34
+ * The device reporting on itself — last report wins. Contains the app version, the ROM's
35
+ * focus-protocol generation, the exact island payload it would attach and whether it holds the
36
+ * microphone. Written by POST /api/notify/diag: the phone writes the answer down where the server
37
+ * can read it, which is the only way to diagnose a device that cannot be plugged into anything.
38
+ */
39
+ diag?: { at: number; text: string } | null;
40
+ }
41
+
42
+ /** Server -> device, one notification. `ref` never leaves the server. */
43
+ export interface NotifyRef {
44
+ /** Handler family: "permission" | "open" | "stop" | ... — the server's switch. */
45
+ type: string;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ export type NotifyOp = "upsert" | "dismiss" | "clear_all" | "probe";
50
+
51
+ export type ActionStyle = "primary" | "danger" | "default";
52
+
53
+ export interface ActivityAction {
54
+ id: string;
55
+ label: string;
56
+ style?: ActionStyle;
57
+ }
58
+
59
+ export interface ActivityProgressSegment {
60
+ length: number;
61
+ color?: string;
62
+ }
63
+
64
+ export interface ActivityProgressPoint {
65
+ position: number;
66
+ color?: string;
67
+ }
68
+
69
+ /** Progress. `segments`/`points` feed Android 16 Notification.ProgressStyle;
70
+ * a device without `progress` support falls back to a plain notification. */
71
+ export interface ActivityProgress {
72
+ value?: number | null;
73
+ indeterminate?: boolean;
74
+ styledByProgress?: boolean;
75
+ segments?: ActivityProgressSegment[];
76
+ points?: ActivityProgressPoint[];
77
+ /** Android drawable name already bundled in the app ("icon:name" convention). */
78
+ trackerIcon?: string;
79
+ startIcon?: string;
80
+ endIcon?: string;
81
+ }
82
+
83
+ /** The server defines channels, the app creates them on demand — that way
84
+ * importance/sound/vibration are server-controlled and never need a new APK. */
85
+ export interface ChannelSpec {
86
+ id: string;
87
+ name: string;
88
+ importance?: "min" | "low" | "default" | "high";
89
+ sound?: boolean;
90
+ vibration?: boolean;
91
+ /** The server took the sound away for a reason of its own (today: a voice call is live, so a tone
92
+ * would be recorded by the agent's own microphone). Behavioural, not cosmetic: an Android channel's
93
+ * sound/importance cannot be changed after creation, so a muted frame must be routed to a quiet
94
+ * channel rather than an updated one. */
95
+ muted?: boolean;
96
+ }
97
+
98
+ export interface ActivityInput {
99
+ enabled: boolean;
100
+ placeholder?: string;
101
+ }
102
+
103
+ export interface ActivityObject {
104
+ schema: 1;
105
+ op: NotifyOp;
106
+ /** Stable id: one id = one notification slot. */
107
+ activityId: string;
108
+ /** Monotonic per activity; a device drops anything older than what it holds. */
109
+ revision: number;
110
+ /** Semantic label ("agent_running" | "approval" | "agent_done" | "error"). */
111
+ kind?: string;
112
+ priority?: "high" | "default" | "low";
113
+ /** Ongoing is a precondition for a promoted Live Update. */
114
+ ongoing?: boolean;
115
+ /** The server's wish; the app promotes only if the device/user allows it. */
116
+ promotable?: boolean;
117
+ /** Which promotion path this frame must take, for testing the two island APIs apart:
118
+ * absent/"" = the app's order (vendor island first, AOSP Live Update after),
119
+ * "xiaomi" = the vendor island only, "aosp" = Android 16's own Live Update only.
120
+ * Behavioural, not cosmetic: it is how "does HyperOS 3 render Android's own chip at all"
121
+ * becomes a button instead of another APK. */
122
+ path?: string;
123
+ title: string;
124
+ subtitle?: string | null;
125
+ body?: string | null;
126
+ progress?: ActivityProgress | null;
127
+ /** Large icon, fetched by the app (server-controlled visuals). */
128
+ iconUrl?: string | null;
129
+ /** Small (status bar) icon — must be a drawable already inside the APK. */
130
+ smallIcon?: string | null;
131
+ channel?: ChannelSpec | null;
132
+ visibility?: "public" | "private" | "secret";
133
+ actions?: ActivityAction[];
134
+ input?: ActivityInput | null;
135
+ /** Where tapping the notification goes (web UI path).
136
+ * @deprecated superseded by `open` — still emitted so an app built before the `open` contract keeps
137
+ * a working tap. Remove once no such app is in the field. */
138
+ deeplink?: string | null;
139
+ /** Where a tap goes, in the abstract: a URL plus how strongly the sender wants it opened in an app.
140
+ *
141
+ * The split is deliberate. The SENDER knows the destination; only the RECEIVER knows what is
142
+ * installed and which origins it trusts, so `prefer` is a hint and the device keeps the policy
143
+ * (see docs/android-notify-contract.md §5.2). This is also what lets a third party push a
144
+ * notification through the same contract: a sender that knows nothing about Agentus sends
145
+ * `{ url, prefer: "web" }` and gets a plain link, while our own server asks for its cockpit. */
146
+ open?: OpenTarget | null;
147
+ staleAt?: number;
148
+ expiresAt?: number;
149
+ }
150
+
151
+ /** How to open an activity when it is tapped. */
152
+ export interface OpenTarget {
153
+ /** Absolute, or relative to the server the activity arrived from (a self-hosted server may not know
154
+ * its own public address — it sits behind a tunnel — so the receiver resolves the rest). */
155
+ url: string;
156
+ /** `app` = the sender asserts an app on this device handles the URL (our cockpit); `web` = always
157
+ * hand it to the platform's web handler; `auto` (default) = the receiver's own rule, which is
158
+ * "in-app if the origin is one of my servers, otherwise the browser". A receiver must never
159
+ * navigate its own authenticated UI to a foreign origin on a payload's say-so. */
160
+ prefer?: "app" | "web" | "auto";
161
+ }
162
+
163
+ /** Device -> server: the operator pressed something on a notification. */
164
+ export interface ActionCallback {
165
+ deviceId: string;
166
+ activityId: string;
167
+ revision: number;
168
+ actionId: string;
169
+ input?: string | null;
170
+ ts: number;
171
+ nonce?: string;
172
+ }
173
+
174
+ /** Server -> device WS frames. */
175
+ export type NotifyFrame =
176
+ | { t: "hello"; deviceId: string; serverTime: number; cursor: number; live: boolean }
177
+ | { t: "activity"; seq: number; activity: ActivityObject }
178
+ | { t: "bye"; reason: string };
179
+
180
+ /** Server-side record: what was published + how to interpret its actions. */
181
+ export interface ActivityRecord {
182
+ seq: number;
183
+ activity: ActivityObject;
184
+ /** Per-action handler refs, keyed by action id. Never sent to a device. */
185
+ actionRefs: Record<string, NotifyRef>;
186
+ publishedAt: number;
187
+ }
188
+
189
+ /**
190
+ * What the phone is allowed to be told. These are the operator's switches, not the app's:
191
+ * the app is a generic renderer, so "do I want a notification for this" is a server-side
192
+ * decision that must be changeable without shipping a new APK.
193
+ */
194
+ export interface NotifyRules {
195
+ /** A turn started (the ongoing "running" card). */
196
+ turnStart: boolean;
197
+ /** The agent is waiting for an approval — the one you answer from the lock screen. */
198
+ approval: boolean;
199
+ /** A turn ended (done / failed). */
200
+ completion: boolean;
201
+ /** Do not push while the operator is looking at that very session in the cockpit. */
202
+ quietWhenWatching: boolean;
203
+ }
204
+
205
+ export const DEFAULT_NOTIFY_RULES: NotifyRules = {
206
+ turnStart: true,
207
+ approval: true,
208
+ completion: true,
209
+ quietWhenWatching: true,
210
+ };
211
+
212
+ /** Which session the cockpit is showing right now (in-memory only — presence is ephemeral). */
213
+ export interface Presence {
214
+ sessionId: string | null;
215
+ visible: boolean;
216
+ /** The operator is on a VOICE CALL with `sessionId` right now (page-reported, same TTL). A call is
217
+ * strictly more than "watching": the call is already reading the replies aloud, and its capture is a
218
+ * plain AudioRecord with no echo cancellation — so any notification tone lands in the agent's own
219
+ * microphone. While a call is live nothing may make a sound (see the center's `onCall`). */
220
+ call?: boolean;
221
+ at: number;
222
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Plan semantics, in one place.
3
+ *
4
+ * ACP plan frames are whole-list snapshots (`plan` in v1 = replace semantics), and an agent's own
5
+ * todo state dies with its PROCESS — which is why a plan used to vanish from the cockpit after a
6
+ * restart (`tasks/20261001-agentus/design-plan-service.md` §1 has the forensics). So the server owns
7
+ * the plan object (Store's `plans` table) and every writer funnels through here, so that they agree
8
+ * on two things:
9
+ *
10
+ * · NORMALISATION — the item shape the card renders. Unknown statuses pass through on purpose:
11
+ * ACP v2 adds `cancelled` and reserves `_`-prefixed unknowns, and a client that drops what it
12
+ * does not understand renders a plan that is quietly wrong.
13
+ * · the TURN LIFECYCLE rules — only the run closes a plan, never the agent (Studio's rule). A
14
+ * leftover `in_progress` step is demoted to `pending` (nothing is running it anymore) and the
15
+ * snapshot carries an honest terminal stamp.
16
+ */
17
+ import type { PlanItem } from "../../../shared/src/index.js";
18
+
19
+ /** Studio caps its plan remark at 1000 chars; take the same bound rather than invent one. */
20
+ export const MAX_EXPLANATION = 1000;
21
+
22
+ export type PlanTerminal = "ended" | "interrupted" | "failed";
23
+ export type PlanSource = "acp" | "mcp" | "server";
24
+
25
+ /** Coerce whatever a writer sent (agent frame, MCP tool) into what the card renders. */
26
+ export function normalizeItems(raw: unknown): PlanItem[] {
27
+ if (!Array.isArray(raw)) return [];
28
+ const out: PlanItem[] = [];
29
+ for (const entry of raw) {
30
+ if (!entry || typeof entry !== "object") continue;
31
+ const e = entry as Record<string, unknown>;
32
+ const content = String(e.content ?? "").trim();
33
+ if (!content) continue;
34
+ const item: PlanItem = {
35
+ content,
36
+ // An entry with no status is not "completed" and not "in_progress": it is something the
37
+ // agent listed and has not said anything else about.
38
+ status: typeof e.status === "string" && e.status ? e.status : "pending",
39
+ };
40
+ if (typeof e.priority === "string" && e.priority) item.priority = e.priority;
41
+ out.push(item);
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** Anything the run still owes the operator. `completed`/`cancelled` are closed; an UNKNOWN status
47
+ * counts as unfinished — a step we cannot interpret should not be reported as done. */
48
+ export function hasUnfinished(items: PlanItem[]): boolean {
49
+ return items.some((i) => i.status !== "completed" && i.status !== "cancelled");
50
+ }
51
+
52
+ /** Demote steps that cannot still be running, because the run that owned them is over. */
53
+ export function demoteInProgress(items: PlanItem[]): PlanItem[] {
54
+ return items.map((i) => (i.status === "in_progress" ? { ...i, status: "pending" } : i));
55
+ }
56
+
57
+ /** Studio's bound and Studio's tolerance: a non-string is dropped, not stringified. */
58
+ export function clampExplanation(raw: unknown): string | null {
59
+ if (typeof raw !== "string") return null;
60
+ const s = raw.trim();
61
+ if (!s) return null;
62
+ return s.length > MAX_EXPLANATION ? s.slice(0, MAX_EXPLANATION) : s;
63
+ }
64
+
65
+ /** One writer per session — and it is the AGENT's, whenever it has something to say.
66
+ *
67
+ * Both channels can exist for the same session, and two writers means two plans racing on one card.
68
+ * The rule is not "whoever typed first" but *native priority*: a frame is the agent's own plan, so
69
+ * it is NEVER refused — it takes the card over from a tool-written plan — and once a frame has
70
+ * landed, `current` is "acp" and the tool is refused from then on. Failures land in the visible
71
+ * direction: the plan on screen is the agent's.
72
+ *
73
+ * Since every backend is MCP-driven by default, `incoming: "acp"` is now only ever seen from a row
74
+ * that opted back into frames (session-manager's `#framesAccepted` drops the rest at the door), so
75
+ * this is a backstop rather than the everyday path. */
76
+ export function acceptsWriteFrom(current: PlanSource | undefined, incoming: PlanSource): boolean {
77
+ if (incoming === "acp") return true; // the agent's own channel outranks any tool
78
+ if (incoming === "server") return true; // the turn lifecycle is not a competing writer
79
+ if (!current || current === "server") return true;
80
+ return current === incoming;
81
+ }
82
+
83
+ /** Whether the plan TOOL may write this session's plan.
84
+ *
85
+ * The comparison above is between the object's STORED source and the incoming one, and on an
86
+ * MCP-driven row that is the wrong yardstick: the agent's frames are dropped at the door, so the
87
+ * tool is the only writer the session has, and it must be able to take over a plan that an older
88
+ * build wrote from frames. Judging by the stored source alone strands exactly those sessions — the
89
+ * card keeps pre-flip content that nothing can update, and the tool answers "the frames wrote this"
90
+ * about frames the cockpit is now ignoring.
91
+ *
92
+ * A row that opted back into `acp` keeps the old ranking: its frames win, the tool is refused. */
93
+ export function acceptsToolWrite(current: PlanSource | undefined, rowRendersFrames: boolean): boolean {
94
+ if (!rowRendersFrames) return true;
95
+ return acceptsWriteFrom(current, "mcp");
96
+ }
97
+
98
+ /** ACP v2's plan updates (`session/update` → `plan_update`), behind an experiment switch.
99
+ *
100
+ * v2 is still a draft: the SDK ships it under an experimental subpath and both ends of this wire
101
+ * declare protocolVersion 1, so NOTHING may depend on it yet. Parsing it anyway costs one branch,
102
+ * and it buys the property that matters for a draft schema — a client that ignores a frame it does
103
+ * not understand renders a plan that is quietly wrong. Off unless asked for: an experiment switch,
104
+ * not a feature flag. */
105
+ export function v2PlanEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
106
+ return env.AGENTUS_ACP_V2_PLAN === "1";
107
+ }
108
+
109
+ /** Fold a v2 `plan_update` payload back into the v1 frame shape, or null when it carries nothing a
110
+ * step list can render.
111
+ *
112
+ * v2 wraps the entries one level deeper (`plan: { planId, type: "items", entries }`) and allows
113
+ * two other content kinds — `markdown` and `file` — which are NOT step lists; guessing a list out
114
+ * of them would put words on the card the agent never wrote as steps. It also allows several plans
115
+ * per session (`planId`), which is why the id is returned rather than ignored: the caller decides
116
+ * what to do with a second one. Kept pure (and exported) so the mapping can be unit-tested against
117
+ * the draft schema without a server. */
118
+ export function foldPlanUpdate(update: Record<string, unknown>): { entries: PlanItem[]; planId: string; meta: unknown } | null {
119
+ const plan = (update?.plan ?? null) as { planId?: unknown; type?: unknown; entries?: unknown } | null;
120
+ if (!plan || plan.type !== "items") return null;
121
+ return {
122
+ entries: normalizeItems(plan.entries),
123
+ planId: String(plan.planId ?? ""),
124
+ meta: update._meta ?? null,
125
+ };
126
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """Run one command on a REAL pseudo-terminal and shovel bytes between it and stdio.
3
+
4
+ This is the whole trick behind a web terminal (ttyd does the same thing in C): `forkpty` gives the
5
+ child a controlling terminal, so the shell believes a human is at the keyboard — line editing,
6
+ prompts, colours, job control, full-screen programs (vim, top) all work. Without it (pipe stdio)
7
+ the shell is in non-interactive mode: no prompt on screen, no echo, no cursor, SIGWINCH meaningless,
8
+ and any terminal emulator in front of it is a lie.
9
+
10
+ Why a Python helper instead of node-pty: node-pty is a build-time native module and this project
11
+ stays dependency-free on install. Python 3 is present on macOS and essentially every Linux; its
12
+ stdlib already has forkpty + termios, which is all a PTY needs. If python3 is missing the server
13
+ falls back to pipes and says so in the panel.
14
+
15
+ Wire format, all raw bytes:
16
+ stdin (fd 0) -> keystrokes from the browser, written straight to the PTY master
17
+ stdout (fd 1) <- everything the PTY produces, written straight out (no filtering, no ANSI
18
+ stripping: a terminal emulator consumes the escape sequences, a <pre> cannot)
19
+ fd 3 <- control channel, one command per line: "resize <cols> <rows>"
20
+
21
+ Sizing: the initial size comes from AGENTUS_PTY_COLS / AGENTUS_PTY_ROWS, and `resize` applies
22
+ TIOCSWINSZ to the master — the kernel then signals the child's foreground process group (SIGWINCH),
23
+ which is what makes vim redraw at the new width.
24
+
25
+ Exit: when the child exits we exit with its status; when stdin hits EOF (the socket closed) or we
26
+ are signalled, we kill the child's process group first so no shell is orphaned.
27
+ """
28
+ import errno
29
+ import fcntl
30
+ import os
31
+ import pty
32
+ import select
33
+ import signal
34
+ import struct
35
+ import sys
36
+ import termios
37
+
38
+ CTL_FD = 3
39
+
40
+
41
+ def set_winsize(fd: int, cols: int, rows: int) -> None:
42
+ cols = max(1, min(cols, 1000))
43
+ rows = max(1, min(rows, 1000))
44
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
45
+
46
+
47
+ def main() -> int:
48
+ argv = sys.argv[1:] or [os.environ.get("SHELL") or "/bin/sh"]
49
+ try:
50
+ cols = int(os.environ.get("AGENTUS_PTY_COLS") or 80)
51
+ rows = int(os.environ.get("AGENTUS_PTY_ROWS") or 24)
52
+ except ValueError:
53
+ cols, rows = 80, 24
54
+
55
+ pid, master = pty.fork()
56
+ if pid == 0: # child: this IS the shell now
57
+ os.environ["TERM"] = os.environ.get("TERM") or "xterm-256color"
58
+ try:
59
+ os.execvp(argv[0], argv)
60
+ except Exception as exc: # noqa: BLE001 - report it on the terminal rather than dying mute
61
+ os.write(2, f"cannot start {argv[0]}: {exc}\r\n".encode())
62
+ os._exit(127)
63
+
64
+ set_winsize(master, cols, rows)
65
+
66
+ ctl = None
67
+ try:
68
+ ctl = os.fdopen(CTL_FD, "r", buffering=1)
69
+ except OSError:
70
+ ctl = None # no control channel: a fixed-size terminal is still a terminal
71
+
72
+ stdin_fd = 0
73
+ running = True
74
+ while running:
75
+ watch = [master, stdin_fd] + ([CTL_FD] if ctl else [])
76
+ try:
77
+ ready, _, _ = select.select(watch, [], [])
78
+ except OSError as exc:
79
+ if exc.errno == errno.EINTR:
80
+ continue
81
+ break
82
+
83
+ if master in ready:
84
+ try:
85
+ data = os.read(master, 65536)
86
+ except OSError:
87
+ data = b""
88
+ if not data:
89
+ break # the child closed the pty: it is gone
90
+ os.write(1, data)
91
+
92
+ if stdin_fd in ready:
93
+ try:
94
+ data = os.read(stdin_fd, 65536)
95
+ except OSError:
96
+ data = b""
97
+ if not data:
98
+ break # EOF: the browser went away, take the shell with us
99
+ os.write(master, data)
100
+
101
+ if ctl and CTL_FD in ready:
102
+ line = ctl.readline()
103
+ if line == "":
104
+ ctl.close()
105
+ ctl = None
106
+ continue
107
+ parts = line.split()
108
+ if parts and parts[0] == "resize" and len(parts) == 3 and parts[1].isdigit() and parts[2].isdigit():
109
+ set_winsize(master, int(parts[1]), int(parts[2]))
110
+
111
+ # Reap the child, then leave with its status so the server can report a real exit code.
112
+ status = 0
113
+ try:
114
+ _, raw = os.waitpid(pid, 0)
115
+ status = os.waitstatus_to_exitcode(raw) if hasattr(os, "waitstatus_to_exitcode") else 0
116
+ except ChildProcessError:
117
+ pass
118
+ except KeyboardInterrupt:
119
+ pass
120
+ return 0 if status is None else status
121
+
122
+
123
+ def _terminate(_signum, _frame): # noqa: ANN001 - signal handler
124
+ # The server kills our process group; this is the polite path when only we are signalled.
125
+ try:
126
+ os.kill(0, signal.SIGKILL)
127
+ except OSError:
128
+ pass
129
+ raise SystemExit(0)
130
+
131
+
132
+ if __name__ == "__main__":
133
+ signal.signal(signal.SIGTERM, _terminate)
134
+ signal.signal(signal.SIGHUP, _terminate)
135
+ sys.exit(main())