@sporhq/spor 0.23.0 → 0.24.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/API.md +15 -3
- package/bin/spor.js +256 -62
- package/lib/shell/agent-dispatch-runner.js +295 -0
- package/lib/shell/dispatch-harnesses.js +96 -0
- package/package.json +2 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "spor",
|
|
3
3
|
"displayName": "Spor Context Compiler",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.24.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "losthammer"
|
|
8
8
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Spor",
|
package/API.md
CHANGED
|
@@ -526,9 +526,21 @@ own. **MCP-only — no REST twin.**
|
|
|
526
526
|
Plain HTTPS + JSON, bearer auth on every route, versioned under `/v1/`. Each
|
|
527
527
|
endpoint is the REST twin of a core call:
|
|
528
528
|
|
|
529
|
+
All graph-derived reads are viewer-scoped by the server's graph-native FGA
|
|
530
|
+
closure (`task-spor-graph-native-fga-read-visibility-filter`). Visibility comes
|
|
531
|
+
from graph relations—area stewardship/membership, direct assignment/routing/
|
|
532
|
+
review, authorship/initiator/asker, and agent ownership—not project claims or an
|
|
533
|
+
admin bit copied into the token. `stewards -> org-root` is the graph-wide read
|
|
534
|
+
relation. Hidden direct ids deliberately use the ordinary `404 not_found` shape;
|
|
535
|
+
list/search/queue/digest/lens/MCP surfaces omit them before ranking or rendering.
|
|
536
|
+
An edge whose target is invisible is absent from adjacency, returned
|
|
537
|
+
frontmatter, raw node markdown, history text, and node exports, so it cannot act
|
|
538
|
+
as an id/title/body oracle. An authenticated token with no resolvable person or
|
|
539
|
+
agent subject sees no graph content; a coarse read-only/CI flag is not a bypass.
|
|
540
|
+
|
|
529
541
|
| Endpoint | Typical caller | Semantics |
|
|
530
542
|
|---|---|---|
|
|
531
|
-
| `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. `?titles=1` adds `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
|
|
543
|
+
| `GET /v1/status` | session-start, monitoring | `{node_count, projects: {...}, head, uptime, metrics}`; doubles as the health check. Graph counts/projects are the viewer-visible projection. `?titles=1` adds viewer-visible `titles: [{id, type, project, title}]` — the one-round-trip graph index the distiller dedups against |
|
|
532
544
|
| `GET /v1/schema` | `spor schema`, agents introspecting the contract | the live schema registry as data (task-spor-schema-introspection-surface; server half task-spor-server-schema-endpoint): `{default_edge_weight, node_types: [{type, description, prefix, always_on, traversable, capturable, queueable, non_resolving, hooks, schema_id, schema_version, source}], edge_types: [{type, description, weight, weight_default, inverse_label, aliases, capturable, hooks, ...}], queue_policy, policies, registers, stale_overrides, alias_collisions}` — the seed pack MERGED with graph-resident `type: schema` overrides, each entry tagged by `source` (`seed`/`graph`/`native`) and the active schema node's id+version. `?code=1` embeds each hook's source under `code: {name: src}` (omitted by default to keep the response lean). The registry IS the contract (norm-cc-registry-is-contract); this read surface closes the failure mode of agents reverse-engineering it from `lib/seed/` files (which miss resident overrides). The REST/MCP twin of the `spor schema` CLI: all three render one `graph.registry.snapshot()` so they never drift |
|
|
533
545
|
| `GET /v1/me` | `spor whoami`/`status`, onboarding | identity echo for the bearer token → `{person, name, email, bound, is_admin, org}`. `bound:false` means the token authenticates but maps to **no person node** (legacy/OAuth, or minted before the node existed), so routed questions and the personal queue will be empty — the client warns on it (the silent identity-degradation signal). `is_admin` reflects the `stewards→root` edge that gates the token-admin surface. `org` is the slug this tenant routes to (`SPOR_ORG`/legacy `SUBSTRATE_ORG`, else `"local"`); it lets a client key its `(issuer, org)` credential store for an **opaque** `spor_oat_`/`spor_pat_` token that carries no readable `org` claim — the client falls back to it after `--org` and the JWT `org` claim (task-spor-frontdoor-me-org-echo). A connector JWT's `org` claim is enforced equal to this echo |
|
|
534
546
|
| `GET /v1/me/org-choices` | `spor auth list` (live membership refresh) | re-queries the IdP's *current* org membership for the held credential's subject and returns `{org_choices: [{slug, label, default?}], source: "idp"\|"bound"}` — `source:"idp"` is a true live enumeration (orgs added/removed since the last login surface without re-authenticating); `source:"bound"` means a single org-scoped token the server couldn't expand (no enumeration). The client treats only `source:"idp"` as live and **fails open** to its cached tenant listing on anything else — `source:"bound"`, a `502 {error.code:"membership_requery_failed"}` (IdP unreachable), a `404` (older server without the endpoint), or any transport/parse error (task-spor-cli-auth-list-live-membership-requery; server half task-spor-frontdoor-held-credential-membership-requery) |
|
|
@@ -537,7 +549,7 @@ endpoint is the REST twin of a core call:
|
|
|
537
549
|
| `DELETE /v1/me/tokens/{hash-prefix}` | `spor token revoke` | revoke one of the caller's OWN PATs by hash prefix → `{revoked, hash_prefix, oauth_grants_revoked}`; a prefix that isn't one of the caller's is `404` (never another person's token). `403` if unbound. Shares the admin revoke's OAuth-grant cascade-completeness invariant (issue-cc-pat-revoke-cascades-all-oauth-grants) |
|
|
538
550
|
| `GET /v1/briefing/{project}` | session-start | read the `brief-<project>` node → `{found, version, body, project_brief?, graph_status}`. The slug resolves through project-node aliases (GRAPH.md "Project identity nodes") before lookup. A BARE repo slug also rides up to its home-project grouping: the grouping's `brief-<grouping>` node returns alongside as `project_brief` (the product context spanning sibling repos), matching the shared up-resolution (dec-spor-queue-slug-resolves-to-grouping); passing the repo NODE id (`repo-<slug>`) is the escape hatch that returns only the repo brief, no `project_brief`. Optional `?fp=root:<sha>,remote:<host/path>,...` carries the repo's fingerprints: the server learns them onto the owning project node, and an unknown slug with a known fingerprint files an alias proposal in the queue |
|
|
539
551
|
| `POST /v1/digest` `{query, root?, project?, min_sim?}` | prompt-context, /spor:brief | digest-mode compile → `{found, text}`; `found: false` is a successful empty result. `root` is the structural-walk twin of `query` (the two are mutually exclusive; `root` wins, an unknown id is `422`). Optional `project` is the session slug: the server scopes the compile to it — the same-project relevance boost, the grouping union, and the `always_on` norm `applies_to_*` ride-along — resolving the slug through project-node aliases/groupings inside compile (dec-spor-queue-slug-resolves-to-grouping), exactly as `/v1/queue` does. A bad slug is `422`; **omitting `project` runs the digest project-blind (byte-identical to before)**, so older clients that send only `{query}` are unaffected |
|
|
540
|
-
| `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics;
|
|
552
|
+
| `GET /v1/nodes/{id}` | /spor:brief | `get_node` semantics; hidden and absent ids both return `404 not_found`. The node's active schema may attach read-time enrichment via a `get(node, ctx)` hook (GRAPH.md) — the seed `question`/`issue`/`task`/`incident` schemas attach `resolution`: a live **visible** inbound resolves/answers edge carrying the resolver's `summary`/`title` and a `lagging` flag (set when it contradicts a still-open status, clear when the node is already terminal, e.g. an answered question pointing at its answer). Open visible gardener findings about the node ride along as `open_findings`, and a node marked stale by a visible inbound supersedes edge as `superseded_by`. Returned `raw`/`frontmatter.edges` redact invisible edge targets. All enrichment is additive top-level keys; ignore unknown ones. Reserved: a top-level `inert` boolean — this node's status evaluated against the FULL type-aware queue-liveness-dead partition, including graph-resident schema overrides a graph-less client can't see (issue-spor-type-blind-terminal-status-fallbacks). The graph-less client callers that need it (`bin/spor.js` `dispatchResolutionReason`, `distill.js` `sessionEndLease`) already read it opportunistically when present and fall back to an offline seed-registry check otherwise, but no server response emits the key yet — implementing it server-side is separate, tracked work |
|
|
541
553
|
| `GET /v1/nodes/{id}/history?limit=N` | `spor history <id>`, the `node_history` MCP tool | per-node commit lineage — a `git log` projection over `nodes/{id}.md` → `{id, head, count, history: [{sha, short, actor, actor_name, actor_email, date, message, internal, person}]}`, newest first. Each revision is labeled `internal:true` for a server-internal write (boot reconcile / migration, `server@spor.invalid`) vs. a real actor, and mapped to its `person` node by author email. Deliberately NOT `git log --follow` (node files share heavy frontmatter boilerplate, so similarity-based rename detection crosses node boundaries — dec-spor-node-history-git-log-projection). `limit` defaults to 50, max 200. The frontmatter `author` re-stamps to the LAST editor on every write, so this is the only durable record of the full chain of editors. A node with no commit history (unknown id) is `404`; a bad id is `422`. The `spor history <id>` CLI verb is the shell front-door (remote reads this; local mode runs the same projection over the graph home) |
|
|
542
554
|
| `GET /v1/nodes/{id}/history/{sha}` | `spor history <id> <sha>`, `node_history` (sha mode) | one revision's detail, the expensive half gated behind an explicit per-sha fetch → the history record for that commit plus `{change, patch, content}`: the change type (`A`/`M`/`D`/`R`), the patch this commit introduced to the node file, and the full node content at that revision (`null` when the commit deleted it). The `sha` must be one from the node's own history — a sha that didn't touch the node, or an unresolvable sha, is `404`; a malformed sha is `422` |
|
|
543
555
|
| `POST /v1/nodes` | `spor put-node`, drain-outbox, mechanical writers | `put_node` semantics, batch: `{nodes: [...], if_exists: "skip"}` (entries may be raw strings or `{node, if_exists, revision}`) → `{results: [...]}`, 207 when any entry failed. Entries are applied **sequentially** and each is fully validated before the next — including the completion-resolver gate that runs on create (GRAPH.md "the resolver gate") — so a born-terminal node (`done` task / `resolved` issue) must have its resolving `decision`/`artifact` EARLIER in the same batch (**resolver-first ordering**; the batch does not defer the gate to end-of-batch, dec-spor-batch-create-gate-resolver-first-ordering). The 207 is partial-success: entries already applied before a later entry's failure are not rolled back |
|
|
@@ -566,7 +578,7 @@ endpoint is the REST twin of a core call:
|
|
|
566
578
|
| `GET /v1/lens/{id}/render?format=html\|text\|json` | browsers, teammates without a checkout | run a lens OR workspace node and render its view tree (html default, plain text, or the raw tree as json). Read-only — no action forms; writes stay with `/v1/nodes` and the MCP tools. Auth is the caller's bearer header OR a signed read-only **render ticket** for shared links (browser links can't carry an Authorization header): `?ticket=<blob>` is accepted once and exchanged via a 302 for an HttpOnly `spor_render_ticket` cookie (kept out of URLs, logs, and view-to-view hrefs). The ticket binds `$viewer` to the recorded sharer and the render shows a "Viewing as <sharer>" banner. The former `?token=<PAT>` sharing path is **removed** — a shared link can never carry a write-capable credential |
|
|
567
579
|
| `POST /v1/lens/{id}/ticket` `{expires?}` | sharing a view; `spor share` | mint a signed, expiring, read-only render ticket for the lens/workspace, recording the authenticated caller as the sharer → `{ticket, url, lens_id, sharer_person_id, exp}`. `expires` is `<N>d` or an ISO date (default `7d`, max `30d`); the caller must be bound to a person node (else `422 no_person`). The ticket carries no write scope and is honored only on the render route (directly, or via the app host's ticket exchange below). The minted `url` depends on host role: an MCP-only host (`SPOR_HOST_ROLE=mcp`) with `SPOR_APP_URL` set mints an **absolute** `${SPOR_APP_URL}/views/{id}?ticket=...` — the app host's own render page, since the MCP host itself 404s on HTML renders; unset, it falls back to today's relative shape; every other role keeps its existing `oauth.baseUrl(request.raw)`-based absolute `/v1/lens/{id}/render?ticket=...`. On the app host, `GET /views/{id}` accepts that `?ticket=` exactly once: it is exchanged via a 302 into an HttpOnly `spor_render_ticket` cookie (stripped from the URL, kept out of logs and view-to-view hrefs) and replayed to api as the credential on the render fetch — but a **live app-host session outranks the ticket**: if the visitor is already signed in, their own session is used and the ticket cookie is ignored, so a shared link can never pin a signed-in user's view to the sharer's `$viewer`. The `spor share <lens-id> [--expires <Nd>]` CLI verb is the shell front-door (remote-only — tickets are minted and signed server-side); it prints the shareable link ready to paste, `--json` for the raw envelope |
|
|
568
580
|
| `POST /v1/merge` `{nodes: [...], mode?: "plan"\|"apply", id_map?: {...}, trust_attached_code?: bool, force?: bool}` | admin promoting one graph into another — pilot-to-org, or a local dogfood graph into a hosted tenant | bring another graph's exported node files (`nodes`: an array of raw node markdown strings) into this one without the failure mode of the naive `GET /v1/export \| POST /v1/nodes --if-exists skip` — silently DROPPING every colliding node while imported edges that pointed at it re-bind to this graph's unrelated same-named node (ordinal id schemes like `cap-<date>-<n>` collide across any two independently started graphs). **Admin-gated** (stewards→root, else `403 forbidden`): `mode:"apply"` writes through the same trusted bulk-import door the server uses internally, which preserves each incoming node's original attribution (a merge moves history, it does not re-author it) and skips the `transitions()`/policy gates (content validation still runs per node; create-only, one deferred commit). Every incoming node classifies as **imported** (id unknown here), **deduped** (id collides, content identical — attribution-blind, this graph's copy wins), **remapped** (id collides, content differs, and the id's final dash-segment is all-digits/*ordinal* — rewritten to `<id>-<sha256(content)[:7]>`, with every reference to the old id across the incoming batch rewritten to match), or **conflict** (id collides with different content and a *semantic* id; a `person` node's email already bound to a different id; or a `schema`/`workflow`/`workflow-run` node or a `stewards`-to-this-graph's-root edge — none of these ever merge silently). Conflicts are reported for manual triage and never written; the schema/workflow class is the one skippable via `trust_attached_code: true` (only for a whole graph you own). `mode:"plan"` (the default) runs the same classification and validation and returns the report without writing anything; `mode:"apply"` **refuses with `409 conflict`** (nothing written) whenever the plan still carries conflicts or validation errors, unless `force: true` — which imports the clean subset and knowingly leaves any reference to a skipped id unresolved. `id_map` (`{"old-id": "new-id"}`) seeds cross-id rewrites; feed a plan's own `id_map` back into the next request when a graph is too large for one batch (plan every batch first to build the complete map, then apply each). Response: `{mode, counts: {incoming, imported, deduped, remapped, conflicts, errors}, imported, deduped, remapped, conflicts, errors, id_map, results?, generated_at}` — `imported`/`deduped`/`remapped`/`conflicts` are arrays of `{id, new_id?, title?, reason?}`; `errors` is `{id, index?, errors: [...]}` (unparseable/invalid entries); `results` (apply mode only) carries the import door's per-entry write verdicts. Deterministic and idempotent — re-running an identical merge dedups everything the first run imported, safe after a partial failure. Called directly today (bearer admin token + `curl`); a CLI wrapper defaulting to plan mode is tracked separately (task-spor-cli-merge-verb) |
|
|
569
|
-
| `GET /v1/export` | bootstrap/offline; `spor export` | ustar tarball of `nodes/` for seeding a local read replica (`?gzip=1` compresses);
|
|
581
|
+
| `GET /v1/export` | bootstrap/offline; `spor export` | ustar tarball of the viewer-visible `nodes/` projection for seeding a local read replica (`?gzip=1` compresses); invisible files and cross-boundary edge lines are omitted. For a root steward this reproduces `nodes/` byte-for-byte. See §5 for response headers. `?history=1` instead streams an inherently unfilterable `git bundle --all` of the repo (`application/x-git-bundle`, full commit provenance, the customer data-exit path — `git clone <bundle> graph`) and is therefore admin-gated (`stewards`→root → `403` otherwise). `?auth=1` ALSO bundles `auth/*.json` so a disaster restore reproduces the credential set and has the same admin gate. The `spor export [--gzip] [--history\|--auth] [--out <file>]` CLI verb is the shell front-door (remote downloads this; `--gzip`/`--out` also build the same `nodes/` tarball locally, while `--history`/`--auth` are remote-only) |
|
|
570
582
|
| `GET /v1/admin/people` | tenant-admin console; offboarding / audit | list every person **subject** → `{people: [{id, name, email, roles, is_admin, status, tokens, active_tokens, last_used}], count}`. `is_admin` reflects the `stewards→root` edge (§4, the same per-person check the admin gate itself runs); `tokens`/`active_tokens`/`last_used` summarize the subject's PATs from the same store `GET /v1/admin/tokens` below lists. `status` is the node's own frontmatter status, stamped `active` at creation — offboarding (below) revokes access without touching it, so it is not an offboarded/active signal today. Admin-only (§4) |
|
|
571
583
|
| `POST /v1/admin/people` `{name, email, id?, roles?, invite?, connection_id?, org?}` | onboarding; the on-box `mint-person.js` CLI, the tenant-admin console | create the canonical person **subject** a PAT or provider callback binds to (task-spor-pilot-person-node-onboarding) — `POST /v1/admin/tokens` below and a provider (IdP) callback both refuse to conjure one, so this is the deliberate step onboarding a new teammate needs FIRST → 201 `{id, name, email, roles, revision, org_invitation?}`. `name`/`email` are required, single-line, ≤200 chars; `id` defaults to an opaque, deterministic email-hash when omitted (never the mutable display name) and must be a `person-<slug>` kebab id; `roles` is an optional array of ≤20 kebab-case role slugs. No `stewards` edge is written — a regular teammate is not an admin (grant that separately, §4). `invite:true` additionally issues a WorkOS Organization invitation for the same email (`connection_id` disambiguates when more than one WorkOS connection is configured; `org` pins the invitation's org, and is honored only on an unbound/self-host server — on a server already bound to one org (the hosted default), a supplied `org` that disagrees with it is `403 forbidden` rather than silently corrected, and omitting it just pins to the bound org) — the invite shape is validated up front so a bad arg never leaves a half-done onboarding, but an invitation failure never rolls back the created person: `org_invitation: {status: "issued"|"failed", ...}` reports either outcome. Admin-only, same `stewards→root` gate as `/v1/admin/tokens`. `409` on a colliding id; `422` invalid |
|
|
572
584
|
| `DELETE /v1/admin/people/{id}` | offboarding | **offboard** a member: revoke every PAT and OAuth grant bound to the subject (the same cascade a directory deprovision runs) → `{offboarded, tokens_revoked, oauth_grants_revoked}`. Does **not** delete the person node — it stays the canonical subject every attribution reference points at, so removal here means "revoke access", not "erase the record". Self-offboarding is refused (`422`) so an admin can't lock themselves out; a malformed or non-`person-` id is also `422` (checked before lookup). Admin-only (§4); `404` unknown id |
|
package/bin/spor.js
CHANGED
|
@@ -22,7 +22,7 @@ const os = require("os");
|
|
|
22
22
|
const path = require("path");
|
|
23
23
|
const https = require("https");
|
|
24
24
|
const crypto = require("crypto");
|
|
25
|
-
const { spawnSync } = require("child_process");
|
|
25
|
+
const { spawn, spawnSync } = require("child_process");
|
|
26
26
|
const { parseArgs } = require("util");
|
|
27
27
|
|
|
28
28
|
const ROOT = path.resolve(__dirname, "..");
|
|
@@ -31,6 +31,8 @@ const remote = require(path.join(ROOT, "lib", "remote.js"));
|
|
|
31
31
|
const auth = require(path.join(ROOT, "lib", "auth.js"));
|
|
32
32
|
const u = require(path.join(ROOT, "scripts", "engines", "util.js"));
|
|
33
33
|
const { gitSpawn } = require(path.join(ROOT, "lib", "shell", "git-exec.js"));
|
|
34
|
+
const dispatchRuns = require(path.join(ROOT, "lib", "shell", "agent-dispatch-runner.js"));
|
|
35
|
+
const dispatchHarnesses = require(path.join(ROOT, "lib", "shell", "dispatch-harnesses.js"));
|
|
34
36
|
const sat = require(path.join(ROOT, "lib", "kernel", "satisfiability.js"));
|
|
35
37
|
// Resolution truth (lib/kernel/resolution.js): a node is "done" when it carries a
|
|
36
38
|
// TERMINAL status OR a live inbound resolves/answers edge — the same partition the
|
|
@@ -386,7 +388,7 @@ async function cmdNext(cfg, args) {
|
|
|
386
388
|
}
|
|
387
389
|
if (needAgents) {
|
|
388
390
|
const q = r.json || {};
|
|
389
|
-
const { items, hidden } = annotateInFlight(q.items || [], dispatchedAgents(), hideDispatched);
|
|
391
|
+
const { items, hidden } = annotateInFlight(q.items || [], dispatchedAgents(cfg), hideDispatched);
|
|
390
392
|
q.items = items;
|
|
391
393
|
if (typeof q.count === "number") q.count = Math.max(0, q.count - hidden);
|
|
392
394
|
// --hide-dispatched shrinks .items below whatever fetchQueuePaged assembled,
|
|
@@ -418,7 +420,7 @@ async function cmdNext(cfg, args) {
|
|
|
418
420
|
// Default path: byte-identical passthrough (no agent cross-reference). Only the
|
|
419
421
|
// --json / --hide-dispatched view captures queue.js's result to annotate it.
|
|
420
422
|
if (!needAgents) return passthrough("queue.js", localArgs);
|
|
421
|
-
return nextLocalInFlight(localArgs, { wantJson, hideDispatched });
|
|
423
|
+
return nextLocalInFlight(cfg, localArgs, { wantJson, hideDispatched });
|
|
422
424
|
}
|
|
423
425
|
|
|
424
426
|
// Local in-flight surface (task-spor-cli-in-flight-surface). The default local
|
|
@@ -429,7 +431,7 @@ async function cmdNext(cfg, args) {
|
|
|
429
431
|
// surfaces; an unparseable stdout falls back to forwarding it verbatim, so an
|
|
430
432
|
// error path is never swallowed. The flags are presentation-only — strip them
|
|
431
433
|
// before handing argv to queue.js (which doesn't know them) and force --json.
|
|
432
|
-
function nextLocalInFlight(localArgs, { wantJson, hideDispatched }) {
|
|
434
|
+
function nextLocalInFlight(cfg, localArgs, { wantJson, hideDispatched }) {
|
|
433
435
|
const passArgs = localArgs.filter((a) => a !== "--json" && a !== "--hide-dispatched");
|
|
434
436
|
passArgs.push("--json");
|
|
435
437
|
const r = spawnSync(process.execPath, [path.join(ROOT, "lib", "queue.js"), ...passArgs], {
|
|
@@ -444,7 +446,7 @@ function nextLocalInFlight(localArgs, { wantJson, hideDispatched }) {
|
|
|
444
446
|
if (r.stdout) process.stdout.write(r.stdout); // forward queue.js's own output
|
|
445
447
|
return status;
|
|
446
448
|
}
|
|
447
|
-
const { items, hidden } = annotateInFlight(q.items || [], dispatchedAgents(), hideDispatched);
|
|
449
|
+
const { items, hidden } = annotateInFlight(q.items || [], dispatchedAgents(cfg), hideDispatched);
|
|
448
450
|
q.items = items;
|
|
449
451
|
if (typeof q.count === "number") q.count = Math.max(0, q.count - hidden);
|
|
450
452
|
if (hideDispatched) q.hidden_dispatched = hidden;
|
|
@@ -488,27 +490,43 @@ function renderQueueLocalText(q, hidden = 0) {
|
|
|
488
490
|
// (every item then reads in_flight:false). SPOR_FAKE_AGENTS_JSON injects canned
|
|
489
491
|
// output for tests, mirroring SPOR_FAKE_MCP_LIST; all claude shell-outs route
|
|
490
492
|
// through claudeCmd() so an SPOR_CLAUDE_CMD stub works too.
|
|
491
|
-
function dispatchedAgents() {
|
|
493
|
+
function dispatchedAgents(cfg) {
|
|
492
494
|
try {
|
|
493
|
-
let text = process.env.SPOR_FAKE_AGENTS_JSON;
|
|
494
|
-
if (text == null) {
|
|
495
|
-
const cmd = claudeCmd();
|
|
496
|
-
if (cmd === "claude" && !hasCmd("claude")) return new Map();
|
|
497
|
-
const r = spawnPortableSync(cmd, ["agents", "--json"], { encoding: "utf8", timeout: 5000 });
|
|
498
|
-
if (r.status !== 0 || !r.stdout) return new Map();
|
|
499
|
-
text = r.stdout;
|
|
500
|
-
}
|
|
501
|
-
const arr = JSON.parse(text);
|
|
502
|
-
if (!Array.isArray(arr)) return new Map();
|
|
503
495
|
const map = new Map();
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
496
|
+
const add = (name, summary) => {
|
|
497
|
+
const list = map.get(name) || [];
|
|
498
|
+
list.push(summary);
|
|
499
|
+
map.set(name, list);
|
|
500
|
+
};
|
|
501
|
+
const home = cfg && typeof cfg.userConfigHome === "function" ? cfg.userConfigHome() : u.userConfigHome();
|
|
502
|
+
for (const adapter of dispatchHarnesses.harnesses()) {
|
|
503
|
+
const discovery = adapter.activeDiscovery || {};
|
|
504
|
+
if (discovery.kind === "run-records") {
|
|
505
|
+
for (const a of dispatchRuns.activeRuns(home)) {
|
|
506
|
+
if (a && a.harness === adapter.id && typeof a.name === "string") add(a.name, a);
|
|
507
|
+
}
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (discovery.kind !== "cli-json") continue;
|
|
511
|
+
let text = process.env.SPOR_FAKE_AGENTS_JSON;
|
|
512
|
+
if (text == null) {
|
|
513
|
+
const cmd = adapter.command();
|
|
514
|
+
if (cmd === "claude" && !hasCmd(cmd)) continue;
|
|
515
|
+
const r = spawnPortableSync(cmd, discovery.args, { encoding: "utf8", timeout: 5000 });
|
|
516
|
+
if (r.status !== 0 || !r.stdout) continue;
|
|
517
|
+
text = r.stdout;
|
|
518
|
+
}
|
|
519
|
+
let arr;
|
|
520
|
+
try { arr = JSON.parse(text); } catch { continue; }
|
|
521
|
+
if (!Array.isArray(arr)) continue;
|
|
522
|
+
for (const a of arr) {
|
|
523
|
+
if (!a || a.kind !== "background" || typeof a.name !== "string") continue;
|
|
524
|
+
if (a.state === "done") continue;
|
|
525
|
+
add(a.name, {
|
|
526
|
+
id: a.id, name: a.name, harness: adapter.id, state: a.state,
|
|
527
|
+
status: a.status, cwd: a.cwd, sessionId: a.sessionId, startedAt: a.startedAt,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
512
530
|
}
|
|
513
531
|
return map;
|
|
514
532
|
} catch {
|
|
@@ -524,9 +542,11 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
524
542
|
// + `cwd` + `startedAt` — the reliable capture path. Match on cwd (the strong
|
|
525
543
|
// signal — we just launched there), then on name when given, then pick the NEWEST
|
|
526
544
|
// (the run we just started). Returns the sessionId or null.
|
|
527
|
-
function newestDispatchedSession(name, dir) {
|
|
545
|
+
function newestDispatchedSession(cfg, name, dir) {
|
|
528
546
|
const all = [];
|
|
529
|
-
for (const arr of dispatchedAgents().values())
|
|
547
|
+
for (const arr of dispatchedAgents(cfg).values()) {
|
|
548
|
+
for (const a of arr) if (!a.harness || a.harness === "claude-code") all.push(a);
|
|
549
|
+
}
|
|
530
550
|
let cands = all.filter((a) => a.sessionId && (!dir || a.cwd === dir));
|
|
531
551
|
if (name) {
|
|
532
552
|
const named = cands.filter((a) => a.name === name);
|
|
@@ -540,10 +560,10 @@ function newestDispatchedSession(name, dir) {
|
|
|
540
560
|
// Capture the launched run's session, polling briefly while the daemon registers
|
|
541
561
|
// it. SPOR_SESSION_ID pins it (tests/reproducibility) and short-circuits the poll.
|
|
542
562
|
// Returns the sessionId or null (fail-open — the caller degrades to session-null).
|
|
543
|
-
async function captureDispatchSession(name, dir, pinned) {
|
|
563
|
+
async function captureDispatchSession(cfg, name, dir, pinned) {
|
|
544
564
|
if (pinned) return pinned;
|
|
545
565
|
for (let i = 0; i < 6; i++) {
|
|
546
|
-
const sid = newestDispatchedSession(name, dir);
|
|
566
|
+
const sid = newestDispatchedSession(cfg, name, dir);
|
|
547
567
|
if (sid) return sid;
|
|
548
568
|
await sleep(300);
|
|
549
569
|
}
|
|
@@ -5534,13 +5554,13 @@ function homeDir() {
|
|
|
5534
5554
|
// The Claude Code binary, overridable for tests (a stub fed via SPOR_CLAUDE_CMD,
|
|
5535
5555
|
// same lever 'spor dispatch' uses). All claude shell-outs route through here.
|
|
5536
5556
|
function claudeCmd() {
|
|
5537
|
-
return
|
|
5557
|
+
return dispatchHarnesses.getHarness("claude-code").command();
|
|
5538
5558
|
}
|
|
5539
5559
|
|
|
5540
5560
|
// The Codex CLI binary, overridable for tests. Codex owns plugin install state,
|
|
5541
5561
|
// so all plugin shell-outs route through this seam instead of writing its cache.
|
|
5542
5562
|
function codexCmd() {
|
|
5543
|
-
return
|
|
5563
|
+
return dispatchHarnesses.getHarness("codex").command();
|
|
5544
5564
|
}
|
|
5545
5565
|
|
|
5546
5566
|
function spawnPortableSync(cmd, args, opts = {}) {
|
|
@@ -6584,7 +6604,7 @@ function dispatchReadinessCheck(cfg, node) {
|
|
|
6584
6604
|
// assigned->agent edge `profile:` attribute > that agent's default uses-profile.
|
|
6585
6605
|
// Returns null when NO profile resolves (no assignment, no profile nodes yet) —
|
|
6586
6606
|
// the common case, leaving dispatch byte-identical. Otherwise
|
|
6587
|
-
// { id, source, found, verdict }: an explicitly-named --profile that can't be
|
|
6607
|
+
// { id, source, found, profile, verdict }: an explicitly-named --profile that can't be
|
|
6588
6608
|
// loaded sets found:false (a hard error the caller reports); an INFERRED profile
|
|
6589
6609
|
// that can't be loaded returns null (fail-open — never block on a dangling edge).
|
|
6590
6610
|
async function resolveDispatchProfile(cfg, { profileFlag, nodeRaw, identityAgent }) {
|
|
@@ -6661,7 +6681,7 @@ async function resolveDispatchProfile(cfg, { profileFlag, nodeRaw, identityAgent
|
|
|
6661
6681
|
/* probe is best-effort; match against what the cascade already holds */
|
|
6662
6682
|
}
|
|
6663
6683
|
const machine = sat.effectiveCapabilities(probed ? { ...rawCap, probed } : rawCap);
|
|
6664
|
-
return { id, source, found: true, verdict: sat.satisfies(machine, profile) };
|
|
6684
|
+
return { id, source, found: true, profile, verdict: sat.satisfies(machine, profile) };
|
|
6665
6685
|
}
|
|
6666
6686
|
|
|
6667
6687
|
// Compile a briefing: a node id -> its neighborhood; free text -> a digest.
|
|
@@ -6764,7 +6784,7 @@ async function topQueueItem(cfg, slug) {
|
|
|
6764
6784
|
items = items.filter((it) => it.suggest !== "triage");
|
|
6765
6785
|
if (!items.length) return null;
|
|
6766
6786
|
// Skip items already in flight on this machine; advance to the first free one.
|
|
6767
|
-
const { items: free, hidden } = annotateInFlight(items, dispatchedAgents(), true);
|
|
6787
|
+
const { items: free, hidden } = annotateInFlight(items, dispatchedAgents(cfg), true);
|
|
6768
6788
|
if (hidden && free.length) {
|
|
6769
6789
|
err(`from-queue: skipped ${hidden} item(s) already in flight on this machine; picking ${free[0].id}`);
|
|
6770
6790
|
}
|
|
@@ -7210,6 +7230,94 @@ function sweepStaleMcpConfigs(dir) {
|
|
|
7210
7230
|
}
|
|
7211
7231
|
}
|
|
7212
7232
|
|
|
7233
|
+
const DISPATCH_RUNNER = path.join(ROOT, "lib", "shell", "agent-dispatch-runner.js");
|
|
7234
|
+
|
|
7235
|
+
function writePrivate(file, text) {
|
|
7236
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
7237
|
+
const fd = fs.openSync(file, "wx", 0o600);
|
|
7238
|
+
try {
|
|
7239
|
+
fs.writeSync(fd, text);
|
|
7240
|
+
} finally {
|
|
7241
|
+
fs.closeSync(fd);
|
|
7242
|
+
}
|
|
7243
|
+
}
|
|
7244
|
+
|
|
7245
|
+
async function launchSupervisedHarness(cfg, {
|
|
7246
|
+
adapter, command, args, cwd, name, nodeId, prompt, server, childToken, mcpToken, bindToken,
|
|
7247
|
+
renewToken, renewNode,
|
|
7248
|
+
}) {
|
|
7249
|
+
const runId = crypto.randomUUID();
|
|
7250
|
+
const p = dispatchRuns.runPaths(cfg.userConfigHome(), runId);
|
|
7251
|
+
const runArgs = args.map((a) => a === "__SPOR_REPORT_PATH__" ? p.report : a);
|
|
7252
|
+
const now = new Date().toISOString();
|
|
7253
|
+
const record = {
|
|
7254
|
+
run_id: runId,
|
|
7255
|
+
node_id: nodeId || null,
|
|
7256
|
+
name,
|
|
7257
|
+
harness: adapter.id,
|
|
7258
|
+
state: "launching",
|
|
7259
|
+
cwd,
|
|
7260
|
+
created_at: now,
|
|
7261
|
+
log_path: p.log,
|
|
7262
|
+
report_path: p.report,
|
|
7263
|
+
};
|
|
7264
|
+
writePrivate(p.prompt, prompt);
|
|
7265
|
+
writePrivate(p.job, JSON.stringify({
|
|
7266
|
+
run_id: runId,
|
|
7267
|
+
harness: adapter.id,
|
|
7268
|
+
command,
|
|
7269
|
+
args: runArgs,
|
|
7270
|
+
cwd,
|
|
7271
|
+
record_path: p.record,
|
|
7272
|
+
prompt_path: p.prompt,
|
|
7273
|
+
log_path: p.log,
|
|
7274
|
+
report_path: p.report,
|
|
7275
|
+
server: server || null,
|
|
7276
|
+
renew_node: renewNode || null,
|
|
7277
|
+
}, null, 2) + "\n");
|
|
7278
|
+
dispatchRuns.atomicJson(p.record, record);
|
|
7279
|
+
|
|
7280
|
+
const runnerEnv = u.gitEnv();
|
|
7281
|
+
for (const key of [
|
|
7282
|
+
"SPOR_DISPATCH_CHILD_TOKEN", "SPOR_DISPATCH_MCP_TOKEN",
|
|
7283
|
+
"SPOR_DISPATCH_BIND_TOKEN", "SPOR_DISPATCH_RENEW_TOKEN",
|
|
7284
|
+
]) delete runnerEnv[key];
|
|
7285
|
+
if (childToken) runnerEnv.SPOR_DISPATCH_CHILD_TOKEN = childToken;
|
|
7286
|
+
if (mcpToken) runnerEnv.SPOR_DISPATCH_MCP_TOKEN = mcpToken;
|
|
7287
|
+
if (bindToken) runnerEnv.SPOR_DISPATCH_BIND_TOKEN = bindToken;
|
|
7288
|
+
if (renewToken) runnerEnv.SPOR_DISPATCH_RENEW_TOKEN = renewToken;
|
|
7289
|
+
let child;
|
|
7290
|
+
let spawnError = null;
|
|
7291
|
+
try {
|
|
7292
|
+
child = spawn(process.execPath, [DISPATCH_RUNNER, p.job], {
|
|
7293
|
+
detached: true,
|
|
7294
|
+
stdio: "ignore",
|
|
7295
|
+
env: runnerEnv,
|
|
7296
|
+
windowsHide: true,
|
|
7297
|
+
});
|
|
7298
|
+
child.on("error", (e) => { spawnError = e; });
|
|
7299
|
+
child.unref();
|
|
7300
|
+
if (child.pid) dispatchRuns.atomicJson(p.record, { ...record, runner_pid: child.pid });
|
|
7301
|
+
} catch (e) {
|
|
7302
|
+
spawnError = e;
|
|
7303
|
+
}
|
|
7304
|
+
|
|
7305
|
+
for (let i = 0; i < 20 && !spawnError; i++) {
|
|
7306
|
+
const state = dispatchRuns.readJson(p.record);
|
|
7307
|
+
if (state && state.state === "failed_launch") {
|
|
7308
|
+
return { ok: false, error: state.error || `${adapter.label} process failed to launch`, runId, paths: p };
|
|
7309
|
+
}
|
|
7310
|
+
if (state && state.state !== "launching") return { ok: true, state, runId, paths: p };
|
|
7311
|
+
await sleep(50);
|
|
7312
|
+
}
|
|
7313
|
+
if (spawnError) {
|
|
7314
|
+
for (const f of [p.job, p.prompt]) try { fs.unlinkSync(f); } catch {}
|
|
7315
|
+
return { ok: false, error: spawnError.message, runId, paths: p };
|
|
7316
|
+
}
|
|
7317
|
+
const state = dispatchRuns.readJson(p.record) || record;
|
|
7318
|
+
return { ok: true, state, runId, paths: p };
|
|
7319
|
+
}
|
|
7320
|
+
|
|
7213
7321
|
async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
7214
7322
|
const dryRun = !!(values.print || values["dry-run"]);
|
|
7215
7323
|
const full = !!values.full;
|
|
@@ -7221,6 +7329,8 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7221
7329
|
const dirOpt = values.dir || null;
|
|
7222
7330
|
const model = values.model || null;
|
|
7223
7331
|
const permMode = values["permission-mode"] || null;
|
|
7332
|
+
const sandbox = values.sandbox || null;
|
|
7333
|
+
const approvalPolicy = values["approval-policy"] || null;
|
|
7224
7334
|
const agent = values.agent || null; // claude --agent (harness agent DEFINITION)
|
|
7225
7335
|
const asAgent = values.as || null; // Spor agent IDENTITY override for dispatch.agent
|
|
7226
7336
|
// A user-supplied prompt template (task-spor-dispatch-user-prompt-templates):
|
|
@@ -7456,7 +7566,7 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7456
7566
|
// mode only (mirrors the auto-claim's scope), in BOTH local and remote (it's a
|
|
7457
7567
|
// local agent read, independent of the graph backend). claude absent / a stale
|
|
7458
7568
|
// exit / unparseable output => empty => no guard (fail-open); --force overrides.
|
|
7459
|
-
const inFlight = nodeId && !backfill ? dispatchedAgents().get(name) || [] : [];
|
|
7569
|
+
const inFlight = nodeId && !backfill ? dispatchedAgents(cfg).get(name) || [] : [];
|
|
7460
7570
|
|
|
7461
7571
|
// Session identity (dec-spor-dispatch-bg-session-late-bind). `claude --bg`
|
|
7462
7572
|
// IGNORES `--session-id` and self-allocates its own run session (verified — it
|
|
@@ -7548,15 +7658,37 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7548
7658
|
return 1;
|
|
7549
7659
|
}
|
|
7550
7660
|
const unsatisfiable = !!(profileCheck && profileCheck.verdict && !profileCheck.verdict.ok);
|
|
7551
|
-
|
|
7552
|
-
const
|
|
7553
|
-
const
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
if (permMode) claudeArgs.push("--permission-mode", permMode);
|
|
7557
|
-
if (agent) claudeArgs.push("--agent", agent);
|
|
7661
|
+
const profileRuntime = (profileCheck && profileCheck.profile) || {};
|
|
7662
|
+
const harness = profileRuntime.harness || "claude-code";
|
|
7663
|
+
const harnessAdapter = dispatchHarnesses.getHarness(harness);
|
|
7664
|
+
const effectiveModel = model || profileRuntime.model || null;
|
|
7665
|
+
const harnessBin = harnessAdapter ? harnessAdapter.command() : null;
|
|
7558
7666
|
// NB: no `--session-id` — `claude --bg` ignores it (warns) and manages its own
|
|
7559
7667
|
// session; we capture the real one post-launch (dec-spor-dispatch-bg-session-late-bind).
|
|
7668
|
+
const previewArgs = harnessAdapter ? harnessAdapter.buildArgs({
|
|
7669
|
+
name,
|
|
7670
|
+
model: effectiveModel,
|
|
7671
|
+
permissionMode: permMode,
|
|
7672
|
+
agent,
|
|
7673
|
+
sandbox: sandbox || "workspace-write",
|
|
7674
|
+
approvalPolicy: approvalPolicy || "never",
|
|
7675
|
+
reportPath: "__SPOR_REPORT_PATH__",
|
|
7676
|
+
sporMcp: null,
|
|
7677
|
+
}) : [];
|
|
7678
|
+
const supportedHarness = !!harnessAdapter;
|
|
7679
|
+
if (!supportedHarness && !dryRun) {
|
|
7680
|
+
err(`cannot dispatch ${nodeId || name}: profile ${profileCheck && profileCheck.id ? profileCheck.id : "(unknown)"} selects unsupported harness '${harness}'.`);
|
|
7681
|
+
err(` this client has adapters for ${dispatchHarnesses.harnesses().map((a) => a.id).join(", ")}; the assignment is unchanged.`);
|
|
7682
|
+
return 1;
|
|
7683
|
+
}
|
|
7684
|
+
const invalidHarnessOptions = harnessAdapter && harnessAdapter.validateOptions({
|
|
7685
|
+
permissionMode: permMode, agent, sandbox, approvalPolicy,
|
|
7686
|
+
});
|
|
7687
|
+
if (invalidHarnessOptions) {
|
|
7688
|
+
err(invalidHarnessOptions.message);
|
|
7689
|
+
err(` ${invalidHarnessOptions.hint}`);
|
|
7690
|
+
return 1;
|
|
7691
|
+
}
|
|
7560
7692
|
|
|
7561
7693
|
if (dryRun) {
|
|
7562
7694
|
out(`dir: ${res.dir} (slug: ${res.slug}, via ${res.source})`);
|
|
@@ -7574,7 +7706,8 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7574
7706
|
out(`onboard: ${steps.join("; ")}`);
|
|
7575
7707
|
}
|
|
7576
7708
|
out(`brief: ${brief ? `${brief.length} bytes` : "(none — graph had nothing relevant, or --no-brief/--backfill)"}`);
|
|
7577
|
-
out(`
|
|
7709
|
+
if (profileCheck) out(`harness: ${harness} (profile ${profileCheck.id})`);
|
|
7710
|
+
out(`session: ${pinnedSession || (harnessAdapter ? harnessAdapter.sessionPreview : "(unsupported harness)")}`);
|
|
7578
7711
|
// Identity preview: what the real dispatch would do for agent-scoping. The
|
|
7579
7712
|
// token mint + 0600 mcp-config are SIDE EFFECTS, so --print only describes
|
|
7580
7713
|
// them (it writes nothing and makes no network call here). Local mode and an
|
|
@@ -7582,7 +7715,11 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7582
7715
|
// session line, which is additive and always present now.
|
|
7583
7716
|
if (identityAgent) {
|
|
7584
7717
|
const src = asAgent ? " (via --as)" : "";
|
|
7585
|
-
out(
|
|
7718
|
+
out(
|
|
7719
|
+
harnessAdapter && harnessAdapter.identityMode === "env-mcp"
|
|
7720
|
+
? `agent: ${identityAgent}${src} (would mint a session-deferred agent token, inject it through env-backed Spor MCP config, then bind the Codex thread after launch)`
|
|
7721
|
+
: `agent: ${identityAgent}${src} (would mint a session-deferred agent-scoped token + write a 0600 --mcp-config, add --strict-mcp-config, then bind the run session after launch)`
|
|
7722
|
+
);
|
|
7586
7723
|
} else if (cfg.mode() === "remote") {
|
|
7587
7724
|
out(`agent: (none configured — 'spor agent use agent-<machine>' or --as to attribute as agent-on-behalf-of; dispatching person-scoped)`);
|
|
7588
7725
|
}
|
|
@@ -7631,7 +7768,10 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7631
7768
|
out(`claim: ${noClaim ? "(--no-claim — lease not established)" : `would establish a lease on ${nodeId} at launch (session bound from the run after launch)`}`);
|
|
7632
7769
|
}
|
|
7633
7770
|
if (template != null) out(`template: ${path.resolve(templateOpt)}`);
|
|
7634
|
-
out(`run: ${
|
|
7771
|
+
if (!supportedHarness) out(`run: (unsupported harness '${harness}')`);
|
|
7772
|
+
else if (harnessAdapter.launchMode === "supervised-jsonl") {
|
|
7773
|
+
out(`run: ${harnessBin} ${previewArgs.map((a) => a === "__SPOR_REPORT_PATH__" ? "<report-path>" : shellQuote(a)).join(" ")} # prompt on stdin`);
|
|
7774
|
+
} else out(`run: ${harnessBin} ${previewArgs.map(shellQuote).join(" ")} <prompt>`);
|
|
7635
7775
|
out(`\n--- prompt ---\n${prompt}`);
|
|
7636
7776
|
return 0;
|
|
7637
7777
|
}
|
|
@@ -7693,8 +7833,9 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7693
7833
|
u.registerRepo(cfg.userConfigHome(), res.slug, res.dir);
|
|
7694
7834
|
if (backfill) out(`registered ${res.slug} → ${res.dir}; launching the backfill agent…`);
|
|
7695
7835
|
|
|
7696
|
-
|
|
7697
|
-
|
|
7836
|
+
const defaultHarnessBin = harnessAdapter.command({});
|
|
7837
|
+
if (harnessBin === defaultHarnessBin && !hasCmd(defaultHarnessBin)) {
|
|
7838
|
+
err(`${harnessAdapter.missingBinary}, then re-run (or 'spor dispatch … --print' to see the prompt).`);
|
|
7698
7839
|
return 1;
|
|
7699
7840
|
}
|
|
7700
7841
|
|
|
@@ -7710,6 +7851,7 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7710
7851
|
// back to the prior person-scoped dispatch with a clear line. Remote + a
|
|
7711
7852
|
// configured agent only; local/unconfigured dispatch is byte-identical.
|
|
7712
7853
|
let agentToken = null;
|
|
7854
|
+
let agentMcpFile = null;
|
|
7713
7855
|
if (identityAgent) {
|
|
7714
7856
|
// Always session-DEFERRED — the run session is bound after launch (below),
|
|
7715
7857
|
// even when SPOR_SESSION_ID pins it (the pin feeds the capture, not the mint),
|
|
@@ -7717,8 +7859,9 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7717
7859
|
const mint = await mintAgentToken(cfg, { agent: identityAgent });
|
|
7718
7860
|
if (mint.ok) {
|
|
7719
7861
|
agentToken = mint.token;
|
|
7720
|
-
|
|
7721
|
-
|
|
7862
|
+
if (harnessAdapter.identityMode === "mcp-file") {
|
|
7863
|
+
agentMcpFile = writeDispatchMcpConfig(cfg, { token: mint.token, key: mcpKey });
|
|
7864
|
+
}
|
|
7722
7865
|
out(`agent: ${identityAgent} (writes attributed agent-on-behalf-of-you; run session bound after launch)`);
|
|
7723
7866
|
} else if (mint.absent) {
|
|
7724
7867
|
err(`warning: this server can't mint agent-scoped session tokens yet — dispatching person-scoped.`);
|
|
@@ -7813,14 +7956,61 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7813
7956
|
out(`worktree: ${wt.dir} (branch ${wt.branch}${wt.reused ? ", reused" : ""}${wt.setupRan ? "; setup ran" : ""})`);
|
|
7814
7957
|
}
|
|
7815
7958
|
|
|
7816
|
-
|
|
7959
|
+
if (harnessAdapter.launchMode === "supervised-jsonl") {
|
|
7960
|
+
const personToken = cfg.mode() === "remote" ? remote.token(cfg) : "";
|
|
7961
|
+
const mcpToken = agentToken || personToken;
|
|
7962
|
+
const wantsSporMcp = harnessAdapter.identityMode === "env-mcp" && cfg.mode() === "remote" && (
|
|
7963
|
+
!!identityAgent || (Array.isArray(profileRuntime.mcp) && profileRuntime.mcp.includes("spor"))
|
|
7964
|
+
);
|
|
7965
|
+
const args = harnessAdapter.buildArgs({
|
|
7966
|
+
model: effectiveModel,
|
|
7967
|
+
sandbox: sandbox || "workspace-write",
|
|
7968
|
+
approvalPolicy: approvalPolicy || "never",
|
|
7969
|
+
reportPath: "__SPOR_REPORT_PATH__",
|
|
7970
|
+
sporMcp: wantsSporMcp && mcpToken ? { url: `${remote.base(cfg)}/mcp` } : null,
|
|
7971
|
+
});
|
|
7972
|
+
const launched = await launchSupervisedHarness(cfg, {
|
|
7973
|
+
adapter: harnessAdapter,
|
|
7974
|
+
command: harnessBin,
|
|
7975
|
+
args,
|
|
7976
|
+
cwd: launchDir,
|
|
7977
|
+
name,
|
|
7978
|
+
nodeId,
|
|
7979
|
+
prompt,
|
|
7980
|
+
server: cfg.mode() === "remote" ? remote.base(cfg) : null,
|
|
7981
|
+
childToken: agentToken,
|
|
7982
|
+
mcpToken: wantsSporMcp ? mcpToken : null,
|
|
7983
|
+
bindToken: agentToken,
|
|
7984
|
+
renewToken: agentToken || personToken,
|
|
7985
|
+
renewNode: nodeId && !backfill && !noClaim ? nodeId : null,
|
|
7986
|
+
});
|
|
7987
|
+
if (!launched.ok) {
|
|
7988
|
+
err(`could not launch ${harnessBin}: ${launched.error}`);
|
|
7989
|
+
await releaseClaimOnAbort();
|
|
7990
|
+
return 1;
|
|
7991
|
+
}
|
|
7992
|
+
out(`run: ${launched.runId} (${harnessAdapter.label} supervisor ${launched.state.state || "launching"})`);
|
|
7993
|
+
out(`log: ${launched.paths.log}`);
|
|
7994
|
+
out(`report: ${launched.paths.report}`);
|
|
7995
|
+
if (launched.state.session_id) out(`session: ${launched.state.session_id}`);
|
|
7996
|
+
return 0;
|
|
7997
|
+
}
|
|
7998
|
+
|
|
7999
|
+
const nativeArgs = harnessAdapter.buildArgs({
|
|
8000
|
+
name,
|
|
8001
|
+
model: effectiveModel,
|
|
8002
|
+
permissionMode: permMode,
|
|
8003
|
+
agent,
|
|
8004
|
+
mcpConfig: agentMcpFile,
|
|
8005
|
+
prompt,
|
|
8006
|
+
});
|
|
7817
8007
|
// The agent's git must follow launchDir (its worktree, or the target checkout),
|
|
7818
8008
|
// so hand it an env scrubbed of the git location vars — an ambient GIT_DIR
|
|
7819
8009
|
// would otherwise point every commit it makes at the LAUNCHER's repo
|
|
7820
8010
|
// (issue-spor-dispatch-worktree-wrong-repo-location).
|
|
7821
|
-
const r = spawnPortableSync(
|
|
8011
|
+
const r = spawnPortableSync(harnessBin, nativeArgs, { cwd: launchDir, stdio: "inherit", env: u.gitEnv() });
|
|
7822
8012
|
if (r.error) {
|
|
7823
|
-
err(`could not launch ${
|
|
8013
|
+
err(`could not launch ${harnessBin}: ${r.error.message}`);
|
|
7824
8014
|
await releaseClaimOnAbort();
|
|
7825
8015
|
return 1;
|
|
7826
8016
|
}
|
|
@@ -7837,7 +8027,7 @@ async function cmdDispatch(cfg, { values, positionals: pos }) {
|
|
|
7837
8027
|
// claimed node).
|
|
7838
8028
|
const wantBind = cfg.mode() === "remote" && (agentToken || (nodeId && !backfill && !noClaim));
|
|
7839
8029
|
if (wantBind) {
|
|
7840
|
-
const realSession = await captureDispatchSession(name, launchDir, pinnedSession);
|
|
8030
|
+
const realSession = await captureDispatchSession(cfg, name, launchDir, pinnedSession);
|
|
7841
8031
|
if (realSession) {
|
|
7842
8032
|
if (agentToken) {
|
|
7843
8033
|
const b = await bindAgentSession(cfg, agentToken, realSession);
|
|
@@ -8943,7 +9133,7 @@ const COMMANDS = {
|
|
|
8943
9133
|
run: (cfg, args) => cmdToken(cfg, args),
|
|
8944
9134
|
},
|
|
8945
9135
|
agent: {
|
|
8946
|
-
group: "Dispatch (
|
|
9136
|
+
group: "Dispatch (background agents)", parse: "raw", args: "create <label> [--owner <id>] [--pubkey <fp>] | list | use <agent-id> | token <agent-id> [list|revoke <prefix>]",
|
|
8947
9137
|
summary: "person-owned automation principals (dispatch identity, standing PATs)",
|
|
8948
9138
|
help:
|
|
8949
9139
|
"Create and list agents — first-class `type: agent` nodes owned by a person\n" +
|
|
@@ -9815,11 +10005,13 @@ const COMMANDS = {
|
|
|
9815
10005
|
|
|
9816
10006
|
// --- Dispatch ---
|
|
9817
10007
|
dispatch: {
|
|
9818
|
-
group: "Dispatch (
|
|
9819
|
-
summary: "compile a briefing + launch
|
|
10008
|
+
group: "Dispatch (background agents)", parse: "strict", args: '"<task>" | <node-id>', aliases: ["bg"],
|
|
10009
|
+
summary: "compile a briefing + launch the profile-selected harness",
|
|
9820
10010
|
help:
|
|
9821
|
-
"Compile a briefing for a task and launch a
|
|
9822
|
-
"
|
|
10011
|
+
"Compile a briefing for a task and launch a background agent in the right repo.\n" +
|
|
10012
|
+
"A resolved profile selects its harness (claude-code or codex); with no profile,\n" +
|
|
10013
|
+
"dispatch preserves the legacy Claude Code path. Give free-text, a <node-id>,\n" +
|
|
10014
|
+
"--node <id>, --from-queue (the top\n" +
|
|
9823
10015
|
"ranked item NOT already in flight on this machine), or --backfill (the\n" +
|
|
9824
10016
|
"unattended init + enable + launch-/spor:backfill primitive; first-time setup\n" +
|
|
9825
10017
|
"goes through the /spor:onboard skill instead). The target dir is the\n" +
|
|
@@ -9861,11 +10053,13 @@ const COMMANDS = {
|
|
|
9861
10053
|
node: { type: "string", value: "id", desc: "dispatch a specific node id" },
|
|
9862
10054
|
slug: { type: "string", value: "slug", desc: "target project slug (cross-repo resolution)" },
|
|
9863
10055
|
as: { type: "string", value: "agent-id", desc: "Spor agent IDENTITY to run as (overrides dispatch.agent; remote-only)" },
|
|
9864
|
-
model: { type: "string", value: "M", desc: "
|
|
10056
|
+
model: { type: "string", value: "M", desc: "harness model override (otherwise profile.model)" },
|
|
9865
10057
|
"permission-mode": { type: "string", value: "P", desc: "claude --permission-mode" },
|
|
10058
|
+
sandbox: { type: "string", value: "S", desc: "codex exec --sandbox (default: workspace-write)" },
|
|
10059
|
+
"approval-policy": { type: "string", value: "P", desc: "codex exec --ask-for-approval (default: never)" },
|
|
9866
10060
|
agent: { type: "string", value: "A", desc: "claude --agent (harness agent DEFINITION — NOT the Spor identity; see --as)" },
|
|
9867
10061
|
profile: { type: "string", value: "profile-id", desc: "profile to run under; checked against this machine's capabilities (overrides the assigned/default profile)" },
|
|
9868
|
-
name: { type: "string", value: "N", desc: "
|
|
10062
|
+
name: { type: "string", value: "N", desc: "dispatch run name (passed to Claude; tracked locally for Codex)" },
|
|
9869
10063
|
template: { type: "string", value: "F", desc: "prompt template file (placeholders above)" },
|
|
9870
10064
|
full: { type: "boolean", desc: "full briefing instead of the digest" },
|
|
9871
10065
|
"no-brief": { type: "boolean", desc: "raw task prompt, no briefing block" },
|
|
@@ -9882,7 +10076,7 @@ const COMMANDS = {
|
|
|
9882
10076
|
run: (cfg, p) => cmdDispatch(cfg, p),
|
|
9883
10077
|
},
|
|
9884
10078
|
repos: {
|
|
9885
|
-
group: "Dispatch (
|
|
10079
|
+
group: "Dispatch (background agents)", parse: "raw",
|
|
9886
10080
|
args: "[list | add <slug> <path> | rm <slug> | tags | tag <slug> [tag...] | untag <slug> [tag...]]",
|
|
9887
10081
|
summary: "the local dispatch slug->dir map, plus repo-identity tags in the graph",
|
|
9888
10082
|
help:
|
|
@@ -9904,7 +10098,7 @@ const COMMANDS = {
|
|
|
9904
10098
|
run: (cfg, args) => cmdRepos(cfg, args),
|
|
9905
10099
|
},
|
|
9906
10100
|
capabilities: {
|
|
9907
|
-
group: "Dispatch (
|
|
10101
|
+
group: "Dispatch (background agents)", parse: "raw", aliases: ["caps", "profiles"],
|
|
9908
10102
|
args: "[list [--json] | show <agent-id> | probe | publish | hosts <profile-id> | set <axis> <v...> | allow-mcp <m...> | deny <profile-id...> | clear]",
|
|
9909
10103
|
summary: "this machine's dispatch capability map (profile satisfiability)",
|
|
9910
10104
|
help:
|
|
@@ -9974,7 +10168,7 @@ const GROUP_ORDER = [
|
|
|
9974
10168
|
"Team admin (remote, admin token)",
|
|
9975
10169
|
"Graph",
|
|
9976
10170
|
"Repo scoping",
|
|
9977
|
-
"Dispatch (
|
|
10171
|
+
"Dispatch (background agents)",
|
|
9978
10172
|
"Other",
|
|
9979
10173
|
];
|
|
9980
10174
|
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// Supervise one foreground coding-agent CLI outside the short-lived
|
|
5
|
+
// `spor dispatch` process. Harness-specific event interpretation lives in the
|
|
6
|
+
// adapter registry; this runner only manages process, journal, and late binding.
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { spawn } = require("child_process");
|
|
11
|
+
const { getHarness } = require("./dispatch-harnesses.js");
|
|
12
|
+
const { whichSync } = require("../../scripts/engines/util.js");
|
|
13
|
+
|
|
14
|
+
function dispatchRunDir(home) {
|
|
15
|
+
return path.join(home, "journal", "dispatch");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runPaths(home, runId) {
|
|
19
|
+
const dir = dispatchRunDir(home);
|
|
20
|
+
return {
|
|
21
|
+
dir,
|
|
22
|
+
record: path.join(dir, `${runId}.run.json`),
|
|
23
|
+
job: path.join(dir, `${runId}.job.json`),
|
|
24
|
+
prompt: path.join(dir, `${runId}.prompt`),
|
|
25
|
+
log: path.join(dir, `${runId}.log`),
|
|
26
|
+
report: path.join(dir, `${runId}.report.md`),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function atomicJson(file, value) {
|
|
31
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
32
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
33
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
|
34
|
+
fs.renameSync(tmp, file);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readJson(file) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pidAlive(pid) {
|
|
46
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function summarizeRun(r) {
|
|
56
|
+
return {
|
|
57
|
+
id: r.run_id,
|
|
58
|
+
run_id: r.run_id,
|
|
59
|
+
name: r.name,
|
|
60
|
+
node: r.node_id || null,
|
|
61
|
+
harness: r.harness,
|
|
62
|
+
state: r.state,
|
|
63
|
+
status: r.state === "running" || r.state === "launching" ? "busy" : r.state,
|
|
64
|
+
cwd: r.cwd,
|
|
65
|
+
pid: r.child_pid || r.runner_pid || null,
|
|
66
|
+
sessionId: r.session_id || null,
|
|
67
|
+
startedAt: r.started_at ? Date.parse(r.started_at) : null,
|
|
68
|
+
log_path: r.log_path,
|
|
69
|
+
report_path: r.report_path,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Active supervised runs for same-machine guards and queue annotation. Confirm
|
|
74
|
+
// the supervisor PID so a hard-killed runner cannot leave a false positive.
|
|
75
|
+
function activeRuns(home, env = process.env) {
|
|
76
|
+
if (env.SPOR_FAKE_DISPATCH_RUNS_JSON != null) {
|
|
77
|
+
try {
|
|
78
|
+
const xs = JSON.parse(env.SPOR_FAKE_DISPATCH_RUNS_JSON);
|
|
79
|
+
return Array.isArray(xs) ? xs : [];
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const dir = dispatchRunDir(home);
|
|
85
|
+
let files = [];
|
|
86
|
+
try {
|
|
87
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".run.json"));
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
const r = readJson(path.join(dir, file));
|
|
94
|
+
if (!r || !["launching", "running"].includes(r.state)) continue;
|
|
95
|
+
if (!pidAlive(r.runner_pid)) continue;
|
|
96
|
+
out.push(summarizeRun(r));
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function portableSpawn(cmd, args, opts, runtime = {}) {
|
|
102
|
+
const platform = runtime.platform || process.platform;
|
|
103
|
+
const spawnImpl = runtime.spawn || spawn;
|
|
104
|
+
if (platform !== "win32") return spawnImpl(cmd, args, opts);
|
|
105
|
+
// npm exposes command shims as .cmd files on Windows. Resolve through PATH +
|
|
106
|
+
// PATHEXT before deciding how to launch, matching the synchronous CLI path.
|
|
107
|
+
const resolved = (runtime.which || whichSync)(cmd) || cmd;
|
|
108
|
+
if (!/\.(?:cmd|bat)$/i.test(resolved)) return spawnImpl(resolved, args, opts);
|
|
109
|
+
const env = (opts && opts.env) || process.env;
|
|
110
|
+
return spawnImpl(env.ComSpec || process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", resolved, ...args], opts);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function finishWritable(stream) {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
if (!stream || stream.writableFinished || stream.destroyed) return resolve();
|
|
116
|
+
let settled = false;
|
|
117
|
+
const done = () => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
stream.off("finish", done);
|
|
121
|
+
stream.off("close", done);
|
|
122
|
+
stream.off("error", done);
|
|
123
|
+
resolve();
|
|
124
|
+
};
|
|
125
|
+
stream.once("finish", done);
|
|
126
|
+
stream.once("close", done);
|
|
127
|
+
stream.once("error", done);
|
|
128
|
+
stream.end();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function post(url, token, body) {
|
|
133
|
+
if (!url || !token) return false;
|
|
134
|
+
const ctrl = new AbortController();
|
|
135
|
+
const timer = setTimeout(() => ctrl.abort(), 3000);
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(url, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
140
|
+
body: JSON.stringify(body),
|
|
141
|
+
signal: ctrl.signal,
|
|
142
|
+
});
|
|
143
|
+
return res.ok;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function runJob(jobFile) {
|
|
152
|
+
const job = readJson(jobFile);
|
|
153
|
+
if (!job || !job.record_path || !job.prompt_path) return 2;
|
|
154
|
+
const adapter = getHarness(job.harness);
|
|
155
|
+
if (!adapter || adapter.launchMode !== "supervised-jsonl") return 2;
|
|
156
|
+
let record = readJson(job.record_path) || {};
|
|
157
|
+
const update = (patch) => {
|
|
158
|
+
record = { ...record, ...patch };
|
|
159
|
+
atomicJson(job.record_path, record);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
let prompt = "";
|
|
163
|
+
try {
|
|
164
|
+
prompt = fs.readFileSync(job.prompt_path, "utf8");
|
|
165
|
+
} catch (e) {
|
|
166
|
+
update({ state: "failed_launch", finished_at: new Date().toISOString(), error: `could not read prompt: ${e.message}` });
|
|
167
|
+
return 2;
|
|
168
|
+
}
|
|
169
|
+
for (const p of [jobFile, job.prompt_path]) {
|
|
170
|
+
try { fs.unlinkSync(p); } catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
fs.mkdirSync(path.dirname(job.log_path), { recursive: true });
|
|
174
|
+
const log = fs.createWriteStream(job.log_path, { flags: "a", mode: 0o600 });
|
|
175
|
+
let logError = null;
|
|
176
|
+
log.on("error", (error) => { logError = error; });
|
|
177
|
+
const childEnv = { ...process.env };
|
|
178
|
+
const childToken = process.env.SPOR_DISPATCH_CHILD_TOKEN || "";
|
|
179
|
+
delete childEnv.SPOR_DISPATCH_CHILD_TOKEN;
|
|
180
|
+
delete childEnv.SPOR_DISPATCH_BIND_TOKEN;
|
|
181
|
+
delete childEnv.SPOR_DISPATCH_RENEW_TOKEN;
|
|
182
|
+
if (childToken) {
|
|
183
|
+
// Never leave the broader person credential available under either the
|
|
184
|
+
// canonical or legacy compatibility spelling in an agent-scoped run.
|
|
185
|
+
delete childEnv.SPOR_TOKEN;
|
|
186
|
+
delete childEnv.SUBSTRATE_TOKEN;
|
|
187
|
+
childEnv.SPOR_TOKEN = childToken;
|
|
188
|
+
childEnv.SUBSTRATE_TOKEN = childToken;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let child;
|
|
192
|
+
try {
|
|
193
|
+
child = portableSpawn(job.command, job.args, {
|
|
194
|
+
cwd: job.cwd,
|
|
195
|
+
env: childEnv,
|
|
196
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
197
|
+
windowsHide: true,
|
|
198
|
+
});
|
|
199
|
+
} catch (e) {
|
|
200
|
+
update({ state: "failed_launch", finished_at: new Date().toISOString(), error: e.message });
|
|
201
|
+
await finishWritable(log);
|
|
202
|
+
return 2;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let launched = false;
|
|
206
|
+
let childError = null;
|
|
207
|
+
let stdinError = null;
|
|
208
|
+
let pending = "";
|
|
209
|
+
let bindPromise = Promise.resolve();
|
|
210
|
+
let sawSession = false;
|
|
211
|
+
|
|
212
|
+
const bindSession = (session) => {
|
|
213
|
+
if (sawSession || !session) return;
|
|
214
|
+
sawSession = true;
|
|
215
|
+
update({ session_id: session });
|
|
216
|
+
bindPromise = (async () => {
|
|
217
|
+
const base = String(job.server || "").replace(/\/+$/, "");
|
|
218
|
+
const bindToken = process.env.SPOR_DISPATCH_BIND_TOKEN || "";
|
|
219
|
+
const renewToken = process.env.SPOR_DISPATCH_RENEW_TOKEN || bindToken;
|
|
220
|
+
if (bindToken) await post(`${base}/v1/agents/session`, bindToken, { session });
|
|
221
|
+
if (job.renew_node && renewToken) {
|
|
222
|
+
await post(`${base}/v1/nodes/${encodeURIComponent(job.renew_node)}/renew`, renewToken, { session });
|
|
223
|
+
}
|
|
224
|
+
})();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const parseLines = (chunk) => {
|
|
228
|
+
pending += chunk;
|
|
229
|
+
const lines = pending.split("\n");
|
|
230
|
+
pending = lines.pop() || "";
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
try {
|
|
233
|
+
const event = JSON.parse(line);
|
|
234
|
+
const session = typeof adapter.sessionFromEvent === "function" ? adapter.sessionFromEvent(event) : null;
|
|
235
|
+
if (session) bindSession(session);
|
|
236
|
+
} catch {
|
|
237
|
+
// JSONL is preserved verbatim even when an adapter does not recognize it.
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// A child can reject args/config/auth before reading a large prompt. Writable
|
|
243
|
+
// pipe failures must be observed before end(prompt), otherwise EPIPE crashes
|
|
244
|
+
// this detached supervisor and leaves a permanently-running journal record.
|
|
245
|
+
child.stdin.on("error", (error) => { stdinError = error; });
|
|
246
|
+
|
|
247
|
+
child.once("spawn", () => {
|
|
248
|
+
launched = true;
|
|
249
|
+
update({
|
|
250
|
+
state: "running",
|
|
251
|
+
runner_pid: process.pid,
|
|
252
|
+
child_pid: child.pid,
|
|
253
|
+
started_at: new Date().toISOString(),
|
|
254
|
+
});
|
|
255
|
+
try {
|
|
256
|
+
child.stdin.end(prompt);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
stdinError = error;
|
|
259
|
+
child.stdin.destroy();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
child.stdout.on("data", (buf) => {
|
|
263
|
+
const text = buf.toString("utf8");
|
|
264
|
+
log.write(text);
|
|
265
|
+
parseLines(text);
|
|
266
|
+
});
|
|
267
|
+
child.stderr.on("data", (buf) => log.write(buf));
|
|
268
|
+
|
|
269
|
+
return new Promise((resolve) => {
|
|
270
|
+
child.on("error", (error) => { childError = error; });
|
|
271
|
+
child.on("close", async (code, signal) => {
|
|
272
|
+
if (pending) parseLines("\n");
|
|
273
|
+
await bindPromise;
|
|
274
|
+
// `close` follows stdout/stderr closure; now finish the journal stream so
|
|
275
|
+
// every parsed event is durable before the terminal run record is visible.
|
|
276
|
+
await finishWritable(log);
|
|
277
|
+
const failure = childError || stdinError || logError;
|
|
278
|
+
const succeeded = launched && code === 0 && !failure;
|
|
279
|
+
update({
|
|
280
|
+
state: launched ? (succeeded ? "done" : "failed") : "failed_launch",
|
|
281
|
+
exit_code: Number.isInteger(code) ? code : null,
|
|
282
|
+
signal: signal || null,
|
|
283
|
+
finished_at: new Date().toISOString(),
|
|
284
|
+
...(failure ? { error: failure.message } : {}),
|
|
285
|
+
});
|
|
286
|
+
resolve(launched ? (succeeded ? 0 : (code || 1)) : 2);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (require.main === module) {
|
|
292
|
+
runJob(process.argv[2]).then((code) => { process.exitCode = code; }).catch(() => { process.exitCode = 2; });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = { dispatchRunDir, runPaths, atomicJson, readJson, activeRuns, summarizeRun, portableSpawn, runJob };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Coding-agent CLI adapters for `spor dispatch`. Keep harness-specific argv,
|
|
4
|
+
// validation, session-event parsing, and discovery declarations here; the CLI
|
|
5
|
+
// owns the common briefing/profile/claim/worktree/supervision lifecycle.
|
|
6
|
+
|
|
7
|
+
function tomlString(value) {
|
|
8
|
+
return JSON.stringify(String(value));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function claudeArgs({ name, model, permissionMode, agent, mcpConfig, prompt }) {
|
|
12
|
+
const args = ["--bg"];
|
|
13
|
+
if (name) args.push("--name", name);
|
|
14
|
+
if (model) args.push("--model", model);
|
|
15
|
+
if (permissionMode) args.push("--permission-mode", permissionMode);
|
|
16
|
+
if (agent) args.push("--agent", agent);
|
|
17
|
+
if (mcpConfig) args.push("--mcp-config", mcpConfig, "--strict-mcp-config");
|
|
18
|
+
if (prompt != null) args.push(prompt);
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function codexArgs({ model, sandbox, approvalPolicy, reportPath, sporMcp }) {
|
|
23
|
+
const args = [
|
|
24
|
+
"--ask-for-approval", approvalPolicy || "never",
|
|
25
|
+
"exec",
|
|
26
|
+
"--json",
|
|
27
|
+
"--sandbox", sandbox || "workspace-write",
|
|
28
|
+
"--output-last-message", reportPath,
|
|
29
|
+
];
|
|
30
|
+
if (model) args.push("--model", model);
|
|
31
|
+
if (sporMcp && sporMcp.url) {
|
|
32
|
+
args.push(
|
|
33
|
+
"--config", `mcp_servers.spor.url=${tomlString(sporMcp.url)}`,
|
|
34
|
+
"--config", `mcp_servers.spor.bearer_token_env_var=${tomlString("SPOR_DISPATCH_MCP_TOKEN")}`,
|
|
35
|
+
"--config", "mcp_servers.spor.required=true"
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
// stdin carries the compiled prompt so it never appears in argv or process
|
|
39
|
+
// listings. The generic supervisor replaces the report placeholder.
|
|
40
|
+
args.push("-");
|
|
41
|
+
return args;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ADAPTERS = Object.freeze({
|
|
45
|
+
"claude-code": Object.freeze({
|
|
46
|
+
id: "claude-code",
|
|
47
|
+
label: "Claude Code",
|
|
48
|
+
launchMode: "native-background",
|
|
49
|
+
identityMode: "mcp-file",
|
|
50
|
+
command: (env = process.env) => env.SPOR_CLAUDE_CMD || "claude",
|
|
51
|
+
activeDiscovery: Object.freeze({ kind: "cli-json", args: ["agents", "--json"] }),
|
|
52
|
+
buildArgs: claudeArgs,
|
|
53
|
+
validateOptions({ sandbox, approvalPolicy }) {
|
|
54
|
+
if (!sandbox && !approvalPolicy) return null;
|
|
55
|
+
const flag = sandbox ? "--sandbox" : "--approval-policy";
|
|
56
|
+
return {
|
|
57
|
+
message: `cannot use ${flag} with a Claude Code dispatch — that flag is Codex-specific.`,
|
|
58
|
+
hint: "use --permission-mode for Claude Code.",
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
sessionPreview: "(allocated by claude --bg at launch, bound after)",
|
|
62
|
+
missingBinary: "claude CLI not on PATH — install Claude Code",
|
|
63
|
+
}),
|
|
64
|
+
codex: Object.freeze({
|
|
65
|
+
id: "codex",
|
|
66
|
+
label: "Codex",
|
|
67
|
+
launchMode: "supervised-jsonl",
|
|
68
|
+
identityMode: "env-mcp",
|
|
69
|
+
command: (env = process.env) => env.SPOR_CODEX_CMD || "codex",
|
|
70
|
+
activeDiscovery: Object.freeze({ kind: "run-records" }),
|
|
71
|
+
buildArgs: codexArgs,
|
|
72
|
+
validateOptions({ permissionMode, agent }) {
|
|
73
|
+
if (!permissionMode && !agent) return null;
|
|
74
|
+
const flag = permissionMode ? "--permission-mode" : "--agent";
|
|
75
|
+
return {
|
|
76
|
+
message: `cannot use ${flag} with a Codex dispatch — that flag is Claude Code-specific.`,
|
|
77
|
+
hint: "Codex runs unattended with --sandbox workspace-write --approval-policy never by default; override those Codex flags explicitly if needed.",
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
sessionFromEvent(event) {
|
|
81
|
+
return event && event.type === "thread.started" ? event.thread_id || null : null;
|
|
82
|
+
},
|
|
83
|
+
sessionPreview: "(read from codex exec thread.started, bound by supervisor)",
|
|
84
|
+
missingBinary: "codex CLI not on PATH — install Codex",
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function getHarness(id) {
|
|
89
|
+
return ADAPTERS[id] || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function harnesses() {
|
|
93
|
+
return Object.values(ADAPTERS);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { getHarness, harnesses };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sporhq/spor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Anthony Allen",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"release": "node scripts/release.js",
|
|
50
50
|
"test": "node --require ./test/helpers/tmp-cleanup.js --test test/*.test.js",
|
|
51
51
|
"test:e2e": "node --require ./test/helpers/tmp-cleanup.js --test test/e2e-claude.test.js",
|
|
52
|
+
"test:e2e:codex": "node --require ./test/helpers/tmp-cleanup.js --test test/e2e-codex-dispatch.test.js",
|
|
52
53
|
"conformance": "node conformance/runner.js"
|
|
53
54
|
},
|
|
54
55
|
"engines": {
|