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.
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +476 -2
- package/README.zh-CN.md +396 -0
- package/bin/agentus.mjs +147 -0
- package/docs/android-notify-contract.md +658 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/docs/refs/aionui-acp-rules.md +46 -0
- package/docs/refs/hermes-studio-ui-ideas.md +191 -0
- package/docs/transcript-folding.md +139 -0
- package/package.json +76 -5
- package/packages/server/mcp/plan-server.mjs +217 -0
- package/packages/server/mock/agent.mjs +693 -0
- package/packages/server/package.json +22 -0
- package/packages/server/src/acp/backends.ts +196 -0
- package/packages/server/src/acp/errors.ts +46 -0
- package/packages/server/src/acp/registry.ts +579 -0
- package/packages/server/src/acp/session-manager.ts +1868 -0
- package/packages/server/src/auth.ts +688 -0
- package/packages/server/src/dashscope.ts +235 -0
- package/packages/server/src/fs.ts +379 -0
- package/packages/server/src/hotwords.ts +132 -0
- package/packages/server/src/index.ts +1486 -0
- package/packages/server/src/notify/center.ts +994 -0
- package/packages/server/src/notify/types.ts +222 -0
- package/packages/server/src/plan/plan.ts +126 -0
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/settings.ts +469 -0
- package/packages/server/src/store/export.ts +209 -0
- package/packages/server/src/store/store.ts +1238 -0
- package/packages/server/src/term.ts +201 -0
- package/packages/server/src/title.ts +109 -0
- package/packages/server/src/voice.ts +251 -0
- package/packages/shared/package.json +14 -0
- package/packages/shared/src/index.ts +271 -0
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/coding-agents/hermes.png +0 -0
- package/packages/web/dist/coding-agents/qoder.svg +5 -0
- package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-icon.svg +301 -0
- package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-mark.svg +301 -0
- package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/packages/web/dist/icons/favicon.ico +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/icon-maskable-512.png +0 -0
- package/packages/web/dist/index.html +30 -0
- package/packages/web/dist/manifest.webmanifest +17 -0
- package/packages/web/dist/sw.js +61 -0
- 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())
|