@volter/supercode-teams 0.2.20
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 +119 -0
- package/attention.mjs +191 -0
- package/bin/fleet.mjs +151 -0
- package/bin/teams.mjs +291 -0
- package/bin/workspace/args.mjs +66 -0
- package/bin/workspace/files.mjs +110 -0
- package/bin/workspace/lifecycle.mjs +113 -0
- package/bin/workspace/volter-device.mjs +42 -0
- package/bin/workspace/volter-login.mjs +185 -0
- package/bin/workspace.mjs +726 -0
- package/browser-attach.mjs +90 -0
- package/client.mjs +212 -0
- package/connector/effects.mjs +95 -0
- package/connector/index.d.ts +69 -0
- package/connector/index.mjs +174 -0
- package/connector/native-continuation.d.ts +8 -0
- package/connector/native-continuation.mjs +50 -0
- package/connector/native-effects.mjs +164 -0
- package/context.mjs +34 -0
- package/contracts/adapter-map.json +16 -0
- package/contracts/index.d.ts +299 -0
- package/contracts/index.mjs +37 -0
- package/execution.mjs +206 -0
- package/fleet.mjs +78 -0
- package/index.mjs +12 -0
- package/machine-door.mjs +36 -0
- package/machine.mjs +1398 -0
- package/package.json +80 -0
- package/porthole-hook.cjs +137 -0
- package/remote/job-exec.py +146 -0
- package/remote/wire.py +23 -0
- package/server/arc2-transactions.mjs +125 -0
- package/server/attachment-relay.mjs +149 -0
- package/server/catalog.mjs +7 -0
- package/server/continuations.mjs +56 -0
- package/server/correlation.d.ts +4 -0
- package/server/correlation.mjs +17 -0
- package/server/effects-transactions.mjs +119 -0
- package/server/effects.mjs +97 -0
- package/server/event-routes.mjs +208 -0
- package/server/exports.mjs +46 -0
- package/server/harness-routes.mjs +248 -0
- package/server/index.d.ts +31 -0
- package/server/index.mjs +52 -0
- package/server/ingest.mjs +24 -0
- package/server/node-upgrade.mjs +39 -0
- package/server/otlp/collector-config.d.ts +2 -0
- package/server/otlp/collector-config.mjs +26 -0
- package/server/otlp/decoders/index.d.ts +63 -0
- package/server/otlp/decoders/index.mjs +311 -0
- package/server/otlp/index.d.ts +4 -0
- package/server/otlp/index.mjs +4 -0
- package/server/otlp/projection.d.ts +5 -0
- package/server/otlp/projection.mjs +17 -0
- package/server/otlp/receiver.d.ts +3 -0
- package/server/otlp/receiver.mjs +36 -0
- package/server/otlp/schema/google/protobuf/any.proto +162 -0
- package/server/otlp/schema/google/rpc/status.proto +48 -0
- package/server/otlp/schema/manifest.json +22 -0
- package/server/otlp/schema/opentelemetry/proto/collector/logs/v1/logs_service.proto +77 -0
- package/server/otlp/schema/opentelemetry/proto/collector/metrics/v1/metrics_service.proto +77 -0
- package/server/otlp/schema/opentelemetry/proto/collector/trace/v1/trace_service.proto +77 -0
- package/server/otlp/schema/opentelemetry/proto/common/v1/common.proto +154 -0
- package/server/otlp/schema/opentelemetry/proto/logs/v1/logs.proto +226 -0
- package/server/otlp/schema/opentelemetry/proto/metrics/v1/metrics.proto +735 -0
- package/server/otlp/schema/opentelemetry/proto/resource/v1/resource.proto +45 -0
- package/server/otlp/schema/opentelemetry/proto/trace/v1/trace.proto +359 -0
- package/server/otlp/transport.d.ts +10 -0
- package/server/otlp/transport.mjs +424 -0
- package/server/otlp/wire-codec.mjs +153 -0
- package/server/store-core.mjs +139 -0
- package/server/store-worker.mjs +23 -0
- package/worker/fetch-adapter.mjs +49 -0
- package/worker/index.mjs +127 -0
- package/worker/websocket-duplex.mjs +29 -0
- package/worker/wrangler.toml +38 -0
- package/workspace-client.d.ts +75 -0
- package/workspace-client.mjs +171 -0
- package/workspace-move.mjs +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# `@volter/supercode-teams`
|
|
2
|
+
|
|
3
|
+
Harness Teams, the harness's part of Volter Teams, has a server and machines. A **machine daemon**, one per OS user, serves this machine's
|
|
4
|
+
doors: panes, launch, files, ports, attention, titles, a signed `describe`, and `harness.v1`.
|
|
5
|
+
A **Teams server** retains a shared session catalog, membership and access rules, and routes
|
|
6
|
+
callers to enrolled machines. It runs as a Node process (`server/index.mjs`) or as a Cloudflare
|
|
7
|
+
Worker with one Durable Object (`worker/`); both host the same `server/core.mjs`. Existing OpenTelemetry pipelines can publish
|
|
8
|
+
evidence without installing Volter Harness on each developer's machine. Start with the
|
|
9
|
+
[Teams guide](../../docs/guides/teams.md); the design is
|
|
10
|
+
[Teams server, CLI and API](../../docs/plans/teams-server.md).
|
|
11
|
+
|
|
12
|
+
The server uses SQLite and requires Node 22.13 or newer. Package exports: `.` (the daemon,
|
|
13
|
+
channel and client), `/machine`, `/client`, `/admission`, `/fleet`, `/server`,
|
|
14
|
+
`/workspace-client`, `/connector`, `/connector/native-continuation` and `/contracts`. The
|
|
15
|
+
workspace client is browser-safe and takes explicit credentials. Native capture and execution use
|
|
16
|
+
the matching harness SDK and native core; source capabilities and fidelity remain explicit.
|
|
17
|
+
Fresh-session materialization supports Claude Code and Codex with requested `value_lossless`;
|
|
18
|
+
telemetry alone does not establish resumable native history.
|
|
19
|
+
|
|
20
|
+
See the [public client declarations](workspace-client.d.ts), [contracts](contracts/index.d.ts),
|
|
21
|
+
and [implementation specification](../../docs/plans/teams-server-build/README.md).
|
|
22
|
+
|
|
23
|
+
## The machine daemon
|
|
24
|
+
|
|
25
|
+
`supercode teams machine start` runs it with its local door only; `supercode teams connect` runs
|
|
26
|
+
the same daemon enrolled to the selected Teams context, adding catalog sync and the server link.
|
|
27
|
+
The daemon owns one `supercode harness serve` and the tmux terminal host.
|
|
28
|
+
|
|
29
|
+
Two doors reach it:
|
|
30
|
+
|
|
31
|
+
- **Local**: an owner-only Unix socket, `$SUPERCODE_HOME/teams/machine.sock` (or, when that path
|
|
32
|
+
is too long for a socket address, a per-user 0700 directory under `/tmp`). Whoever can open it
|
|
33
|
+
is this OS user, an operator. The local CLI, the agent inside the machine and a service on the
|
|
34
|
+
same box (RH2's connector) use it.
|
|
35
|
+
- **Remote**: the Teams server. The daemon holds one outbound WebSocket link; the server forwards
|
|
36
|
+
a caller's channel over it after checking the caller's machine grants, and the daemon
|
|
37
|
+
re-derives that admission from its own synced copy of the grants before every door.
|
|
38
|
+
|
|
39
|
+
The machine's custodian holds it as operator. Anyone else holds a **machine grant**:
|
|
40
|
+
`operate` (the whole machine: `harness.v1` and `launch`), or `view`, `observe`, `interact`,
|
|
41
|
+
`control`, `files`, `ports` on named targets (`pane:<key>`, `runtime:<id>`, `root:/path`,
|
|
42
|
+
`port:<pane>/<n>`), or on every target of one kind (`pane:*`, `port:*`). `*` carries only
|
|
43
|
+
`operate` and `view`: a grant that reaches more names the kind it reaches, so it never covers
|
|
44
|
+
a kind another product serves on the same machine. `title.set` needs `control` on its target.
|
|
45
|
+
`observe` on a `root:/path` reads it (stat, list, read, search); `files` also changes it.
|
|
46
|
+
|
|
47
|
+
A workplace can open one pane or one folder for someone who is not a member of the team. A
|
|
48
|
+
server started with `--delegated-issuer ORIGIN` (the Worker's `DELEGATED_ISSUER`) trusts short-lived
|
|
49
|
+
tokens that issuer signs (Ed25519, keys at `ORIGIN/.well-known/jwks.json`, audience this server's
|
|
50
|
+
public origin), each naming a pane (`pane:<key>`) or a folder (`root:/path`) on one machine of one
|
|
51
|
+
team, and `view` or `control`. The hosted viewer at `/view#token=…` opens it; the server turns the
|
|
52
|
+
token into a machine grant on exactly that target (a pane: `observe`, and `control` to type; a
|
|
53
|
+
folder: `observe` to read, `files` to change) that expires with the token, and closes the channel
|
|
54
|
+
then. A page embedding the viewer sends it a fresh token by `postMessage({ type: 'delegated-token',
|
|
55
|
+
token })`, which the viewer asks for with `delegated-token-needed`.
|
|
56
|
+
|
|
57
|
+
`teams publish` puts a pane or a folder of the connected machine (or `--machine NAME`) into a
|
|
58
|
+
workplace Room as a Surface, through the workplace's own door with a session token of yours;
|
|
59
|
+
`teams unpublish SURFACE` retires it:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
supercode teams publish pane <ctx> --room room_… --workplace https://pilot.runhuman.com --organization acme --token-stdin
|
|
63
|
+
supercode teams publish root ./reports --room room_… --workplace https://pilot.runhuman.com --organization acme --token-stdin
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
supercode teams machine start # local door only
|
|
68
|
+
supercode open --new bash --detach # a pane; prints its contextKey
|
|
69
|
+
supercode open <ctx> # attach; ctrl-] detaches
|
|
70
|
+
supercode teams input <ctx> "make test"
|
|
71
|
+
supercode teams title set <ctx> "rate-limit rescue" # also names the owned tmux window
|
|
72
|
+
supercode teams suspend <ctx> # remote input refused; observe keeps streaming
|
|
73
|
+
supercode teams resume <ctx> --input "carry on" # the input that hands the pane back
|
|
74
|
+
supercode teams log verify # the hash chain across every rotation
|
|
75
|
+
|
|
76
|
+
supercode teams machines access grant laptop --to user:usr_… --caps observe,control --on pane:<ctx> --ttl 1h
|
|
77
|
+
supercode open <ctx> --on laptop # through the selected context's server
|
|
78
|
+
supercode discover --fleet # every reachable machine of the context
|
|
79
|
+
supercode open --new claude --on laptop # a pane on an enrolled machine
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Products served behind the daemon
|
|
83
|
+
|
|
84
|
+
Another product serves its own kind of target on the machine (Volter Browsers:
|
|
85
|
+
`browser-profile:<name>`) through the daemon's `product` stream, opened with
|
|
86
|
+
`{ target: '<kind>:<name>', ...params }`. The product calls `serveProduct` from `products.mjs`,
|
|
87
|
+
which listens on loopback and writes a receipt, `products/<name>.json`, naming the kinds it
|
|
88
|
+
serves, its endpoint and a token. The daemon admits the caller first. Then it dials the newest
|
|
89
|
+
live receipt's endpoint and tells the product the principal, the capabilities held on that
|
|
90
|
+
target and the caller's params. Each side first proves it holds the receipt's token, the
|
|
91
|
+
product before it hears anything, and the token never crosses the wire. The daemon then
|
|
92
|
+
relays text messages both ways, each under the channel's frame limit. The product enforces what each
|
|
93
|
+
capability admits on its kind. A grant on `*` reaches a product's target only for `view`.
|
|
94
|
+
|
|
95
|
+
A remote caller is re-admitted every 15 s and when a grant it holds expires. The daemon decides
|
|
96
|
+
from the grants it holds, then refreshes them from the server, at most once per interval.
|
|
97
|
+
Wider capabilities are passed on; narrower ones close the stream. The server closes a
|
|
98
|
+
member's channels when their membership is deactivated, or when a group they reached a machine
|
|
99
|
+
through loses them or is deleted.
|
|
100
|
+
|
|
101
|
+
`supercode teams browser <profile> --machine <name>` serves a machine's browser profile as a
|
|
102
|
+
local CDP endpoint: `chromium.connectOverCDP('<printed address>')` drives it, each connection
|
|
103
|
+
riding its own `product` stream. The machine's own CDP address and Volter Browsers' publication token
|
|
104
|
+
never leave it. The endpoint answers any local process that reaches it, like a browser's own
|
|
105
|
+
debugging port.
|
|
106
|
+
|
|
107
|
+
A target whose kind has no live receipt is refused as not served on this machine. The
|
|
108
|
+
harness's own kinds (`pane`, `runtime`, `root`, `port`) are never served by a product.
|
|
109
|
+
|
|
110
|
+
## Files (under `$SUPERCODE_HOME/teams`)
|
|
111
|
+
|
|
112
|
+
`machine.key|pub` (the host key; it signs `describe` and nothing else) · `machine.sock` ·
|
|
113
|
+
`machine.json` · `launches.jsonl` · `log.jsonl` (hash-chained, rotating to `log.<ts>.jsonl`) ·
|
|
114
|
+
`attention.json` · `titles.json` · `suspended.json` · `limits.json` · `products/` (receipts of
|
|
115
|
+
products served behind the daemon) · `workspaces/` (Teams contexts, credentials and connector
|
|
116
|
+
enrollments).
|
|
117
|
+
|
|
118
|
+
`limits.json` is read when the daemon starts: `maxAttachmentsPerPrincipal` (16),
|
|
119
|
+
`protocolFloor` (2), `logRotateBytes` (16 MiB), `logKeep` (8).
|
package/attention.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Attention. The split this file exists to hold:
|
|
2
|
+
//
|
|
3
|
+
// the terminal snapshot carries the EVIDENCE — what the Machine can see about
|
|
4
|
+
// a pane: is it live, is a foreground command running, does the screen show a
|
|
5
|
+
// crash or a choice, how long since the screen last moved, and whether anyone
|
|
6
|
+
// declared that this pane needs a human;
|
|
7
|
+
//
|
|
8
|
+
// the node owns `attention.request|clear` and a RANK TABLE as configuration;
|
|
9
|
+
//
|
|
10
|
+
// the label is a PURE FUNCTION of the two, computed on the Machine.
|
|
11
|
+
//
|
|
12
|
+
// Nothing here routes, acks, silences or notifies: `computeLabel`
|
|
13
|
+
// says what the evidence means and `notifies` says whether the label is the kind
|
|
14
|
+
// that warrants interrupting a human. WHO gets paged, and whether they already
|
|
15
|
+
// acknowledged it, is the consumer's — this module has no idea a human exists.
|
|
16
|
+
|
|
17
|
+
/** Normalized liveness of a pane: what the Machine could prove about whether
|
|
18
|
+
* anything is running in it.
|
|
19
|
+
* ended — no live pane with that context key
|
|
20
|
+
* running — a foreground command other than the pane's own shell
|
|
21
|
+
* waiting — the pane's shell is the foreground process: a prompt, turn over
|
|
22
|
+
* unknown — the pane exists but named no foreground command (the honest
|
|
23
|
+
* floor: absence of evidence, never silently reported as running)
|
|
24
|
+
*/
|
|
25
|
+
export const LIVENESS = Object.freeze(['ended', 'running', 'unknown', 'waiting']);
|
|
26
|
+
|
|
27
|
+
/** Every label the vocabulary has, in the default precedence — the order
|
|
28
|
+
* `computeLabel` walks, first guard to match wins. The precedence is data,
|
|
29
|
+
* not control flow, and reads as one line:
|
|
30
|
+
*
|
|
31
|
+
* requested > ended > errored > asking > awaiting_me > stuck > unknown > working
|
|
32
|
+
*
|
|
33
|
+
* requested — someone (usually the agent in the pane) DECLARED it needs a
|
|
34
|
+
* human. Outranks even errored/asking: a declaration is stronger
|
|
35
|
+
* evidence than a screen read, and may be raised for a reason no
|
|
36
|
+
* screen heuristic could ever detect ("I finished, review my PR").
|
|
37
|
+
* It outranks `ended` too, so a request survives its pane.
|
|
38
|
+
* ended — the pane is gone. Checked before the screen rungs because there
|
|
39
|
+
* is no screen left to read; whatever the last capture said, it is
|
|
40
|
+
* not evidence about a pane that no longer exists.
|
|
41
|
+
* errored — the screen shows the agent's own call failing.
|
|
42
|
+
* asking — the screen shows a choice/permission prompt: blocked on an answer.
|
|
43
|
+
* awaiting_me — the shell is at its prompt: the turn ended, it waits on input.
|
|
44
|
+
* stuck — running, but the screen has not moved in `stuckIdleSeconds`.
|
|
45
|
+
* Looks busy, likely wedged. A heuristic, and labelled as one.
|
|
46
|
+
* unknown — live, but no rung said anything. A real look is warranted; it
|
|
47
|
+
* never outranks a state we affirmatively detected.
|
|
48
|
+
* working — live and running. The floor: always true, so the walk terminates.
|
|
49
|
+
*/
|
|
50
|
+
export const LABELS = Object.freeze(['requested', 'ended', 'errored', 'asking', 'awaiting_me', 'stuck', 'unknown', 'working']);
|
|
51
|
+
|
|
52
|
+
/** The labels that warrant interrupting a human.
|
|
53
|
+
*
|
|
54
|
+
* `unknown` is deliberately NOT here: it is an UNPROVEN read — no rung
|
|
55
|
+
* matched this tick — not an affirmative signal. A pane's first capture is
|
|
56
|
+
* often empty, so notifying on `unknown` would page a human every time a pane
|
|
57
|
+
* is born and every time a capture is momentarily unreadable. It still sorts
|
|
58
|
+
* ahead of `working` in the order above, so it is visible to anyone already
|
|
59
|
+
* looking at the list, without paging anyone on a transient miss.
|
|
60
|
+
*
|
|
61
|
+
* `ended` and `working` are absent for the opposite reason: they are proven,
|
|
62
|
+
* and proven-to-be-fine needs nobody.
|
|
63
|
+
*
|
|
64
|
+
* `requested` is absent too, and that one is a deliberate default rather than
|
|
65
|
+
* a reasoned exclusion. It reads oddly — a label whose entire content is "a
|
|
66
|
+
* human is needed here" reports `notify: false` — and an org that wants
|
|
67
|
+
* declarations to page adds one word to its own `notify` list. Written down
|
|
68
|
+
* here so the next reader finds the question already asked.
|
|
69
|
+
*/
|
|
70
|
+
export const DEFAULT_NOTIFY = Object.freeze(['errored', 'asking', 'awaiting_me', 'stuck']);
|
|
71
|
+
|
|
72
|
+
/** A foreground-running pane whose screen has not moved in this long looks busy
|
|
73
|
+
* but is likely wedged (a hung tool call, a crash without an exit). Heuristic,
|
|
74
|
+
* which is why it is configuration: 5 minutes suits an agent mid-turn and is
|
|
75
|
+
* far too long for a fleet of one-second jobs. */
|
|
76
|
+
export const DEFAULT_STUCK_IDLE_SECONDS = 300;
|
|
77
|
+
|
|
78
|
+
/** The rank table as it ships. It lives in `<home>/attention.json` under `rank`,
|
|
79
|
+
* beside the `requests` map, and the ORG EDITS THE FILE — the node re-reads it
|
|
80
|
+
* on every call, so a precedence change takes effect on the next `attention ls`
|
|
81
|
+
* with nothing restarted. Three knobs, and no more:
|
|
82
|
+
*
|
|
83
|
+
* order the precedence walk. Reorder it to change which evidence
|
|
84
|
+
* wins an argument; you cannot invent a label, because a
|
|
85
|
+
* label without a guard below is a label nothing can prove.
|
|
86
|
+
* stuckIdleSeconds how long a silent screen stays `working` before `stuck`.
|
|
87
|
+
* notify which labels warrant interrupting a human.
|
|
88
|
+
*/
|
|
89
|
+
export const DEFAULT_RANK = Object.freeze({
|
|
90
|
+
order: LABELS,
|
|
91
|
+
stuckIdleSeconds: DEFAULT_STUCK_IDLE_SECONDS,
|
|
92
|
+
notify: DEFAULT_NOTIFY,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** What it takes to prove each label, given the evidence tuple. One guard per
|
|
96
|
+
* label; the table above only decides the ORDER they are asked in. Guards are
|
|
97
|
+
* not configuration: a guard is the meaning of the word, and an org that could
|
|
98
|
+
* edit them would be renaming `errored` rather than re-ranking it. */
|
|
99
|
+
const GUARDS = Object.freeze({
|
|
100
|
+
requested: (e) => Boolean(e.requested),
|
|
101
|
+
ended: (e) => e.liveness === 'ended',
|
|
102
|
+
errored: (e) => e.signal === 'errored',
|
|
103
|
+
asking: (e) => e.signal === 'asking',
|
|
104
|
+
awaiting_me: (e) => e.liveness === 'waiting',
|
|
105
|
+
stuck: (e, table) => e.liveness === 'running' && (e.idleSeconds ?? 0) >= table.stuckIdleSeconds,
|
|
106
|
+
unknown: (e) => e.liveness === 'unknown',
|
|
107
|
+
working: () => true,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** The rung each label's verdict actually came from, in the substrate's own
|
|
111
|
+
* `DerivationSource` vocabulary (sdk/terminal/substrate/session-lifecycle.d.ts)
|
|
112
|
+
* so the node speaks one evidence grammar rather than a second private one:
|
|
113
|
+
* agent-declared somebody told the node directly (`attention request`)
|
|
114
|
+
* process-tree tmux session enumeration and its foreground command
|
|
115
|
+
* screen-heuristic `classifyAttentionSignal` over the pane's capture
|
|
116
|
+
* transcript the capture-hash plateau that produces `stuck`
|
|
117
|
+
*/
|
|
118
|
+
const SOURCES = Object.freeze({
|
|
119
|
+
requested: 'agent-declared',
|
|
120
|
+
ended: 'process-tree',
|
|
121
|
+
errored: 'screen-heuristic',
|
|
122
|
+
asking: 'screen-heuristic',
|
|
123
|
+
awaiting_me: 'process-tree',
|
|
124
|
+
stuck: 'transcript',
|
|
125
|
+
unknown: 'process-tree',
|
|
126
|
+
working: 'process-tree',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/** Merge an org's `rank` into the defaults so the walk is always total: unknown
|
|
130
|
+
* names are dropped (a label nothing can prove would silently swallow the rest
|
|
131
|
+
* of the order), and any label the org left out is appended in default order
|
|
132
|
+
* rather than made unreachable — omitting `ended` must not mean a dead pane has
|
|
133
|
+
* no label. */
|
|
134
|
+
export function normalizeRank(raw) {
|
|
135
|
+
const table = raw && typeof raw === 'object' ? raw : {};
|
|
136
|
+
const wanted = Array.isArray(table.order) ? table.order.filter((label) => LABELS.includes(label)) : [];
|
|
137
|
+
const order = [...new Set([...wanted, ...LABELS])];
|
|
138
|
+
const notify = Array.isArray(table.notify) ? table.notify.filter((label) => LABELS.includes(label)) : DEFAULT_NOTIFY;
|
|
139
|
+
const stuck = Number(table.stuckIdleSeconds);
|
|
140
|
+
return {
|
|
141
|
+
order,
|
|
142
|
+
stuckIdleSeconds: Number.isFinite(stuck) && stuck >= 0 ? stuck : DEFAULT_STUCK_IDLE_SECONDS,
|
|
143
|
+
notify: [...notify],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The label, from the four inputs and the table. Pure: same tuple, same table,
|
|
149
|
+
* same answer, on any Machine, with no clock and no I/O of its own.
|
|
150
|
+
*
|
|
151
|
+
* @param {{requested?: unknown, signal?: 'asking'|'errored'|null, liveness: 'ended'|'running'|'unknown'|'waiting', idleSeconds?: number|null}} evidence
|
|
152
|
+
* @param {{order: string[], stuckIdleSeconds: number, notify: string[]}} [rankTable]
|
|
153
|
+
* @returns {string} one of LABELS
|
|
154
|
+
*/
|
|
155
|
+
export function computeLabel(evidence, rankTable = DEFAULT_RANK) {
|
|
156
|
+
const table = normalizeRank(rankTable);
|
|
157
|
+
for (const label of table.order) {
|
|
158
|
+
if (GUARDS[label](evidence, table)) return label;
|
|
159
|
+
}
|
|
160
|
+
return 'working';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Where in the precedence a label sits — the index the caller sorts by, so a
|
|
164
|
+
* list ordered urgent-first never needs its own copy of the order. */
|
|
165
|
+
export function rankOf(label, rankTable = DEFAULT_RANK) {
|
|
166
|
+
return normalizeRank(rankTable).order.indexOf(label);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether this label is the kind that warrants interrupting a human. The node
|
|
170
|
+
* reports the flag; who is interrupted, and whether they already said "I know",
|
|
171
|
+
* belongs to whoever is reading. */
|
|
172
|
+
export function notifies(label, rankTable = DEFAULT_RANK) {
|
|
173
|
+
return normalizeRank(rankTable).notify.includes(label);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The rung that decided this label, for the `source` the evidence carries. */
|
|
177
|
+
export function sourceOf(label) {
|
|
178
|
+
return SOURCES[label] ?? 'process-tree';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Labels decided by liveness alone: when the harness reported that liveness
|
|
182
|
+
* itself, the verdict came from its own lifecycle, not the process tree. */
|
|
183
|
+
const LIVENESS_LABELS = new Set(['awaiting_me', 'working', 'unknown', 'ended']);
|
|
184
|
+
|
|
185
|
+
/** Label + rank + notify in one call, for the node's row builder. */
|
|
186
|
+
export function judge(evidence, rankTable = DEFAULT_RANK) {
|
|
187
|
+
const table = normalizeRank(rankTable);
|
|
188
|
+
const attention = computeLabel(evidence, table);
|
|
189
|
+
const source = evidence.livenessSource && LIVENESS_LABELS.has(attention) ? evidence.livenessSource : sourceOf(attention);
|
|
190
|
+
return { attention, rank: table.order.indexOf(attention), notify: table.notify.includes(attention), source };
|
|
191
|
+
}
|
package/bin/fleet.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The fleet CLI. `supercode discover --fleet`, `supercode discover --on M`,
|
|
3
|
+
// `supercode open [--new] [--on M]` and `supercode harness drive --on M` spawn this; it is also
|
|
4
|
+
// runnable directly as `supercode-teams-fleet …`. It mints no verb of its own:
|
|
5
|
+
// every verb here is the remote arm of a verb supercode already has.
|
|
6
|
+
//
|
|
7
|
+
// discover [--query q] [--limit n] [--machines a,b] [--context c]
|
|
8
|
+
// harness.v1.sessions.discover on every machine of the selected Teams
|
|
9
|
+
// context the caller can reach (or the named subset), merged into one
|
|
10
|
+
// page; each unreachable machine is a row of its own. Nothing is stored.
|
|
11
|
+
// rpc --on <machine> <harness.v1 method> [json] one operator request, one machine
|
|
12
|
+
// launch [--on <machine>] <program> [--cwd d] [--launch-key k] [--kind k] [--input text] -- args
|
|
13
|
+
// (`open --new`)
|
|
14
|
+
// attach [--on <machine>] <pane> [--mode observe|control] ctrl-] detaches (`open --on`)
|
|
15
|
+
//
|
|
16
|
+
// `--on` names an enrolled machine of the selected context, reached through
|
|
17
|
+
// its Teams server. Omitted, every verb acts on this OS user's own daemon, so
|
|
18
|
+
// the remote arm and the local arm are one code path.
|
|
19
|
+
import { attachPane, attachRuntime } from '../client.mjs';
|
|
20
|
+
import { openOn, harnessRequest, nodeRequest, fleetDiscover } from '../fleet.mjs';
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const flags = {};
|
|
24
|
+
const positional = [];
|
|
25
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
26
|
+
const a = argv[i];
|
|
27
|
+
if (a === '--') { positional.push(...argv.slice(i + 1)); break; }
|
|
28
|
+
if (a.startsWith('--')) {
|
|
29
|
+
const eq = a.indexOf('=');
|
|
30
|
+
if (eq > 0) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; } // --args=--norc
|
|
31
|
+
const key = a.slice(2);
|
|
32
|
+
const next = argv[i + 1];
|
|
33
|
+
if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i += 1; } else flags[key] = true;
|
|
34
|
+
} else positional.push(a);
|
|
35
|
+
}
|
|
36
|
+
const [verb, sub, ...rest] = positional;
|
|
37
|
+
|
|
38
|
+
const out = (value) => process.stdout.write((typeof value === 'string' ? value : JSON.stringify(value, null, 2)) + '\n');
|
|
39
|
+
const die = (message, code = 2) => { process.stderr.write(`supercode fleet: ${message}\n`); process.exit(code); };
|
|
40
|
+
const onName = () => (typeof flags.on === 'string' ? flags.on : null);
|
|
41
|
+
const contextName = () => (typeof flags.context === 'string' ? flags.context : null);
|
|
42
|
+
|
|
43
|
+
/** The machine `--on` names in the selected context, or this box's own daemon. */
|
|
44
|
+
function channelForOn() {
|
|
45
|
+
return openOn(onName(), { context: contextName() });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function main() {
|
|
49
|
+
switch (verb) {
|
|
50
|
+
case 'discover': {
|
|
51
|
+
const params = {};
|
|
52
|
+
if (typeof flags.query === 'string') params.query = flags.query;
|
|
53
|
+
if (flags.limit !== undefined) params.limit = Number(flags.limit);
|
|
54
|
+
if (typeof flags.workspace === 'string') params.workspace = flags.workspace;
|
|
55
|
+
if (flags.harness) params.harnesses = String(flags.harness).split(',').filter(Boolean);
|
|
56
|
+
const machines = typeof flags.machines === 'string' ? String(flags.machines).split(',').filter(Boolean) : null;
|
|
57
|
+
out(await fleetDiscover({ context: contextName(), machines, params }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
case 'rpc': {
|
|
61
|
+
if (!sub) die('rpc --on <machine> <harness.v1 method> [json]');
|
|
62
|
+
const channel = await channelForOn();
|
|
63
|
+
try {
|
|
64
|
+
out(await harnessRequest(channel, sub, rest[0] ? JSON.parse(rest[0]) : {}));
|
|
65
|
+
} finally {
|
|
66
|
+
channel.close();
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
case 'launch': {
|
|
71
|
+
// The node's own `launch` door: a pane on the Machine, keyed so a
|
|
72
|
+
// retry of the same launch key never opens a second one.
|
|
73
|
+
const program = typeof flags.program === 'string' ? flags.program : sub;
|
|
74
|
+
if (!program) die('launch --on <machine> --program <program> [--args=…]');
|
|
75
|
+
const channel = await channelForOn();
|
|
76
|
+
try {
|
|
77
|
+
out(await nodeRequest(channel, 'launch', {
|
|
78
|
+
key: flags['launch-key'],
|
|
79
|
+
pane: flags.pane,
|
|
80
|
+
kind: flags.kind,
|
|
81
|
+
program,
|
|
82
|
+
arguments: flags.args ? String(flags.args).split(' ').filter(Boolean) : rest,
|
|
83
|
+
cwd: flags.cwd,
|
|
84
|
+
initialInput: flags.input,
|
|
85
|
+
name: flags.name,
|
|
86
|
+
}));
|
|
87
|
+
} finally {
|
|
88
|
+
channel.close();
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
case 'attach': {
|
|
93
|
+
const pane = sub;
|
|
94
|
+
if (!pane && !flags.runtime) die('attach --on <machine> <pane> [--mode observe|control] | attach --on <machine> --runtime <runtime-id> [--send text]');
|
|
95
|
+
const mode = typeof flags.mode === 'string' ? flags.mode : 'observe';
|
|
96
|
+
const channel = await channelForOn();
|
|
97
|
+
if (flags.runtime || (pane && pane.includes('://'))) {
|
|
98
|
+
// A MANAGED runtime: frontend.v2 over the channel . The node mints this principal
|
|
99
|
+
// its own credential, so control and approvals are the lease coordinator's own semantics.
|
|
100
|
+
const runtime = String(flags.runtime ?? pane).replace(/^supercode-live:\/\//, '');
|
|
101
|
+
const fc = await attachRuntime(channel, { runtime, permissions: mode === 'control' ? ['observe', 'interact', 'approve'] : ['observe'] });
|
|
102
|
+
const descriptor = await fc.describe();
|
|
103
|
+
process.stderr.write(`[attached runtime ${runtime} (${descriptor.harness ?? descriptor.title ?? 'runtime'}) — ${mode}${mode === 'control' ? '; ctrl-] detaches' : ''}]\n`);
|
|
104
|
+
const snapshot = await fc.attach(Number(flags.tail ?? 20));
|
|
105
|
+
for (const cell of snapshot.cells ?? snapshot.messages ?? []) {
|
|
106
|
+
const text = cell.text ?? cell.content ?? '';
|
|
107
|
+
if (text) process.stdout.write(`${cell.role ?? cell.kind ?? ''}: ${String(text).slice(0, 400)}\n`);
|
|
108
|
+
}
|
|
109
|
+
fc.on('event', (event) => {
|
|
110
|
+
const text = event.text ?? event.payload?.text ?? null;
|
|
111
|
+
if (event.type === 'output_delta' && text) process.stdout.write(text);
|
|
112
|
+
else if (event.type && event.type !== 'output_delta') process.stderr.write(`[${event.type}${text ? ': ' + String(text).slice(0, 120) : ''}]\n`);
|
|
113
|
+
});
|
|
114
|
+
fc.on('disconnect', () => { process.stderr.write('\n[runtime detached]\n'); channel.close(); process.exit(0); });
|
|
115
|
+
if (mode === 'control') await fc.takeControl();
|
|
116
|
+
if (typeof flags.send === 'string') { await fc.sendInput(flags.send); process.stderr.write(`[sent]\n`); }
|
|
117
|
+
if (mode === 'control' && process.stdin.isTTY) {
|
|
118
|
+
process.stdin.setRawMode(false);
|
|
119
|
+
let line = '';
|
|
120
|
+
process.stdin.on('data', async (chunk) => {
|
|
121
|
+
const s = chunk.toString('utf8');
|
|
122
|
+
if (s.includes('')) { fc.detachChannel(); channel.close(); process.exit(0); }
|
|
123
|
+
line += s;
|
|
124
|
+
if (line.endsWith('\n')) { const prompt = line.trimEnd(); line = ''; if (prompt) await fc.sendInput(prompt).catch((e) => process.stderr.write(`[${e.message}]\n`)); }
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
await fc.startEvents({}).catch((e) => { process.stderr.write(`[events ended: ${e.message}]\n`); });
|
|
128
|
+
if (typeof flags.for === 'string') setTimeout(() => { fc.detachChannel(); channel.close(); process.exit(0); }, Number(flags.for));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const term = await attachPane(channel, { pane, mode, cols: process.stdout.columns ?? 120, rows: process.stdout.rows ?? 40 });
|
|
132
|
+
term.on('hello', (frame) => process.stderr.write(`[attached ${pane} ${frame.cols}x${frame.rows}${mode === 'control' ? ' — ctrl-] detaches' : ''}]\n`));
|
|
133
|
+
term.on('error-message', (message) => process.stderr.write(`[${message}]\n`));
|
|
134
|
+
term.on('data', (text) => process.stdout.write(text));
|
|
135
|
+
term.on('close', (reason) => { process.stderr.write(`\n[detached${reason ? `: ${reason}` : ''}]\n`); channel.close(); process.exit(0); });
|
|
136
|
+
if (mode === 'control' && process.stdin.isTTY) {
|
|
137
|
+
process.stdin.setRawMode(true);
|
|
138
|
+
process.stdin.on('data', (chunk) => {
|
|
139
|
+
if (chunk[0] === 0x1d) { term.close(); return; } // ctrl-] detaches
|
|
140
|
+
term.write(chunk.toString('utf8'));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
process.stdout.on('resize', () => term.resize(process.stdout.columns, process.stdout.rows));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
die('verbs: discover, rpc, launch, attach');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
main().catch((error) => die(error.message ?? String(error), 1));
|