Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Added
- **TensorFold status on the device heartbeat**: `uik-daemon` and `ide-agent-kit intent daemon` can publish the live status of each TensorFold server in the model registry under the device's `tensorfold` key, so CodeWatch can show a card per machine. Opt in with `INTENT_TENSORFOLD_REGISTRY=<path to models.json>`; `INTENT_TENSORFOLD_SCOPE=local` (default, loopback entries only) or `all`. Read with GET `/health`, `/metrics` and `/v1/slide/graph` only, never a completion. Publishes model, warming, version/backend/device when the server reports them, memory, running and waiting requests, token counters, tok/s from counter deltas between reads, draft acceptance, TTFT p50, KV cache use, Living Weights fact counts (never fact text) and the metrics auth state. An unreachable server is `reachable: false`; an unreadable registry is an `error`, never an empty list. New `ide-agent-kit tensorfold-status [--json]` prints the same object. The registry gains `engine` (`"tensorfold"` marks an entry, any other name skips it, absent means auto-detect). A key is sent only to an entry marked TensorFold that names its own `keyFile`, and only on the two gated routes; the fleet-wide `LLM_API_KEY` fallbacks are never used for a status read.
- **What is still waiting on you** (`bin/iak-pending.mjs`): asks every daemon the fleet knows about - this machine's plus every `watchdog-roster.json` entry with a `gate` - for its pending intents and prints them oldest first with the age in the leading column. Motivation: confirmation cards post to the room correctly and then scroll away; on 2026-09-20 four approval requests sat pending on one machine for up to 9 hours 12 minutes and the owner asked twice in one night what was waiting on him, because no surface anywhere could answer it. A host that could not be reached is reported under its own `COULD NOT ASK` heading and is never folded into "nothing pending"; a gate naming another machine by LAN IP is refused rather than probed and reported the same way. A daemon whose rows carry no `status` this version understands is a shape mismatch rather than an empty queue, and a fleet roster that exists but cannot be parsed or read exits `2` instead of silently shrinking the fleet to this machine (only a *missing* roster means "single-machine install"). An age that cannot be believed - missing, future-dated, or epoch-seconds - renders as `age unknown`, sorts to the top and survives `--older-than`, rather than being clamped to "fresh". Exit codes: `0` all clear, `10` pending, `11` some hosts unreachable, `12` no host reachable, `2` cannot run, precedence `12 > 11 > 10 > 0`; `--older-than` narrows what is printed and announced but can never produce a `0`. `--json` for machines. `--room` requires `--older-than` and announces each item at most once per escalation (threshold, then each doubling of its age), keeping a ledger at `~/.iak/pending-announced.json` that is written atomically before the post, carries forward entries for hosts it could not ask, rolls back when a post fails, and degrades to silence - never a storm - when it is missing, corrupt or unwritable. `examples/pending-launchd.plist` wires it to a timer. The gate token is resolved by the existing `resolveGateToken` helper, is sent only to hosts that helper already trusts, and never appears in output or argv.
- **Session degradation watch** (`bin/iak-degradation-watch.mjs`): a per-machine daemon that tails watched agents' Claude Code session transcripts and posts a room alert (pushed to the owner's phone via CodeWatch) the moment a session's `message.model` leaves the configured accepted set or its `permissionMode` differs from the expected one. Motivation: on 2026-07-18 an agent's session silently slipped to a small fallback model with manual permission prompts while the owner was away; the degraded agent kept posting — looping on stale tasks and reposting completed instructions — and cost an evening to diagnose. Alerts fire immediately on first detection (a wrong model in a transcript is a fact, not a flap) with a 30-minute per-signal cooldown and a recovery note. Config: `degradation_watch` block (`interval_sec`, `alert_room`, `agents[]` with `transcript_dir`, `accepted_models` prefix list, `expected_permission_mode`); API key reuses `poller.api_key`. `--once` and `--dry-run` flags for testing. Run one instance per machine under process supervision.
- **Send-to-session**: the :8788 daemon gains `POST /sessions/send {agent, text, from?}` — deliver text as user input into any named agent's live session — plus `GET /sessions/agents` for the target picker. Four delivery adapters, configured per agent under `mcp.confirmations.sessions.agents`: `wake` (spawn a wake/injection script), `gui` (same, chained behind a human-idle guard with `--wait` so it never types over the user), `tmux` (literal `send-keys` + Enter), and `forward` (relay to the owning machine's daemon, provenance passed through so the `[from …]` prefix is applied exactly once). Deliveries are accepted-async (202) and every attempt is receipted. This is the backend half of the CodeWatch send box; it drives any agent uniformly, including headless sessions the provider apps cannot reach.
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,28 @@ npx uik-daemon # standalone equivalent, INTENT_* env vars

The IDE Agent Kit and User Intent Kit are designed to be deployed together: IAK consumes the intent state for gating, UIK publishes your own heartbeats. Without the daemon your slot goes stale and other agents will treat you as offline.

#### TensorFold status on the device card

IAK, TensorFold, CodeWatch and GroupMind are meant to work as one system. If
you run a [TensorFold](https://github.com/ThinkOffApp/TensorFold) server, the
heartbeat can carry its live status (model, memory, requests, tok/s, draft
acceptance, time to first token, KV cache use, Living Weights) so CodeWatch
can show a card for it on each machine.

1. List the server in `config/models.json` with `"engine": "tensorfold"` (or
leave `engine` out and let it be auto-detected). See
`config/models.README.md`.
2. Check what the card will say: `ide-agent-kit tensorfold-status` (add
`--json` for the exact object).
3. Turn it on for the publisher: set
`INTENT_TENSORFOLD_REGISTRY=/path/to/config/models.json` in the
environment of `uik-daemon` or `ide-agent-kit intent daemon`, and restart it.

It is read with GET requests only (`/health`, `/metrics`, `/v1/slide/graph`),
never a completion, and an unreachable server shows as unreachable instead of
stopping the heartbeat. The payload shape is in
`packages/user-intent-kit/README.md`.

UIK v0.2.2 or later recommended for the `uik-daemon` bin. v0.2.0 introduced the daemon, v0.2.1 fixed a silent-exit in the example file, and v0.2.2 added agent-status republish on the heartbeat cadence so the agent slot does not expire while the daemon is running. Pre-0.2.2 daemons publish device state correctly but the agent slot still goes stale after its TTL.

### Enrichment Configuration
Expand Down Expand Up @@ -1185,6 +1207,7 @@ ide-agent-kit gateway <health|agents|trigger|wake> [options]
ide-agent-kit memory <list|get|set|append|delete|search> [options]
ide-agent-kit rooms archive [--backfill] [--max-pages N] [--room R]
ide-agent-kit rooms search "words" [--from handle] [--since 2026-09-20] [--regex] [--limit N]
ide-agent-kit tensorfold-status [--json] [--registry <path>] [--local] [--timeout <ms>]
ide-agent-kit init [--ide <claude-code|codex|cursor|vscode|gemini>] [--profile <balanced|low-friction>]
ide-agent-kit acp <spawn|list|status|send|close> [options]
ide-agent-kit keepalive <start|stop|status> [--pid-file <path>] [--heartbeat-sec <sec>]
Expand Down
21 changes: 21 additions & 0 deletions bin/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ Usage:
state: overall_state + reachability + background gate summary
patch: update this device's slot: intent patch key=value [key=value ...]
daemon: long-running publisher (desktop state + agent heartbeat) [--interval <sec>]
Add INTENT_TENSORFOLD_REGISTRY=<models.json> to publish TensorFold status on each beat.

ide-agent-kit tensorfold-status [--json] [--registry <path>] [--local] [--timeout <ms>] [--config <path>]
Live status of every TensorFold server in the model registry, read with GET only
(/health, /metrics, /v1/slide/graph). Same object the device heartbeat publishes.

ide-agent-kit poll --rooms <room1,room2> --api-key <key> --handle <@handle> [--interval <sec>] [--config <path>]
(Legacy) Poll GroupMind rooms with explicit CLI args. Prefer "rooms watch".
Expand Down Expand Up @@ -562,6 +567,22 @@ async function main() {
process.exit(1);
}

// ── TensorFold status (GET only) ───────────────────────
if (command === 'tensorfold-status') {
const opts = parseKV(args, 'tensorfold-status');
const config = loadConfig(opts.config);
const { resolveTensorFoldRegistryPath, runTensorFoldStatus } = await import('../src/tensorfold-status.mjs');
const registryPath = resolveTensorFoldRegistryPath({ cliPath: typeof opts.registry === 'string' ? opts.registry : undefined, config });
const timeoutMs = opts.timeout ? Number(opts.timeout) : undefined;
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
console.error('Error: --timeout must be a positive number of milliseconds');
process.exit(2);
}
const result = await runTensorFoldStatus({ registryPath, scope: opts.local ? 'local' : 'all', json: !!opts.json, timeoutMs });
(result.exitCode === 0 ? console.log : console.error)(result.text);
process.exit(result.exitCode);
}

// ── Rooms ──────────────────────────────────────────────
if (command === 'rooms') {
const opts = parseKV(args, subcommand || 'rooms');
Expand Down
39 changes: 38 additions & 1 deletion config/models.README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Copy the example to `config/models.json` and edit it; `config/models.json` is
the file `bin/model-capacity.mjs` reads by default.

```json
{ "models": [ { "id": ..., "host": ..., "port": ..., "kind": ..., "box": ..., "sharing": ..., "owner": ..., "auth": ..., "keyFile": ... } ] }
{ "models": [ { "id": ..., "host": ..., "port": ..., "kind": ..., "engine": ..., "box": ..., "sharing": ..., "owner": ..., "auth": ..., "keyFile": ... } ] }
```

A bare top-level array is also accepted.
Expand All @@ -18,6 +18,7 @@ A bare top-level array is also accepted.
| `host` | yes | **Tailnet MagicDNS name** (`asus1`, `mini`) or tailnet IP (`100.x.y.z`). See the rule below. |
| `port` | yes | Integer 1-65535. The OpenAI-style HTTP port, not the ssh port. |
| `kind` | yes | `openai` \| `vllm` \| `lmstudio`. Decides which list endpoint is called: `lmstudio` uses `/api/v1/models` and counts only loaded instances, the other two use `/v1/models`. |
| `engine` | no | Which server software answers on this port. `tensorfold` turns on the TensorFold status read-out for this entry (see below). Any other short name (`vllm`, `llama.cpp`, `ollama`) says "not TensorFold" and skips auto-detection. Omit it to let the status reader auto-detect. |
| `box` | no | Free text for humans: which machine, where it lives. Shown in `--json`, never probed. |
| `sharing` | no | `exclusive` \| `shared`, default `shared`. Advisory metadata for the switcher's ranking. The probe applies the same BUSY rules to both - it is documented in `docs/model-capacity.md` rather than silently inferred. |
| `owner` | no | Handle of whoever to ask before taking the box. |
Expand Down Expand Up @@ -64,6 +65,42 @@ two hosts behind the same router were in opposite path states to the same
Berlin box at the same moment. A value written into this shared file is wrong
for every caller except the one who wrote it.

## TensorFold entries

`ide-agent-kit tensorfold-status` and the device heartbeat (when
`INTENT_TENSORFOLD_REGISTRY` points at this file) read the live status of
every TensorFold server listed here: model, warming, memory, requests, token
counters, tok/s, draft acceptance, time to first token, KV cache use and
Living Weights. They send **GET /health, GET /metrics and GET /v1/slide/graph
only**, never a completion.

Which entries count as TensorFold:

* `"engine": "tensorfold"`: always, reachable or not.
* no `engine`: auto-detected when `/metrics` carries `tensorfold:` series or
`/health` has TensorFold's shape. A server started with an API key hides
both, so mark a keyed server `"engine": "tensorfold"` explicitly.
* any other `engine`: never asked.

**Keys.** A TensorFold server may gate `/metrics` and `/v1/slide/graph`
behind a key (`/health` stays open, and says only `{"status": "ok"}` while
keys are on). The status reader sends a key only to an entry marked
`"engine": "tensorfold"` that names its own `keyFile`, and only on those two
routes. It never uses `LLM_API_KEY` / `LLM_API_KEY_FILE`: those belong to some
other server as far as a status read knows. Without a usable key it reports
`metrics: "auth_required"` and the rest of the card reads unknown.

```json
{
"id": "gb10-tensorfold",
"host": "asus3",
"port": 8080,
"kind": "openai",
"engine": "tensorfold",
"keyFile": "~/.iak/tensorfold_gb10_api_key.txt"
}
```

## The rule: tailnet names, never LAN IPs

`host` must not be a private LAN address. The loader rejects `10.x.x.x`,
Expand Down
22 changes: 22 additions & 0 deletions config/models.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@
"box": "Mac mini (always-on)",
"sharing": "shared",
"owner": "@myagent"
},
{
"id": "mb-tensorfold",
"host": "localhost",
"port": 8091,
"kind": "openai",
"engine": "tensorfold",
"box": "This Mac, TensorFold serving one model (loopback only)",
"sharing": "shared",
"owner": "@myagent"
},
{
"id": "gb10-tensorfold",
"host": "asus3",
"port": 8080,
"kind": "openai",
"engine": "tensorfold",
"box": "ASUS Ascent GX10 (GB10), native TensorFold started with an API key file",
"sharing": "exclusive",
"owner": "@myagent",
"auth": "bearer",
"keyFile": "~/.iak/example_tensorfold_api_key.txt"
}
]
}
83 changes: 83 additions & 0 deletions packages/user-intent-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,89 @@ export INTENT_MODEL_ENDPOINT=127.0.0.1:8080
as a last resort for a box with no endpoint to ask; a server that answers
always outranks it.

### TensorFold servers on the device card

Point the daemon at a model registry (ide-agent-kit's `config/models.json`)
and each TensorFold server in it gets its live status on this machine's
device card:

```bash
export INTENT_TENSORFOLD_REGISTRY=~/ide-agent-kit/config/models.json
export INTENT_TENSORFOLD_SCOPE=local # default; `all` also reads remote entries
```

- **GET only.** `/health`, `/metrics` and `/v1/slide/graph`, never a
completion, so a status read cannot interfere with a Living Weights lesson.
- **Off unless set.** Nothing is read or published without the registry path.
- **Not on the heartbeat's path.** It reads on its own timer at the heartbeat
interval; the beat publishes the last reading and never waits on a socket.
- **One key, replaced whole.** Everything goes under `tensorfold`, so a server
removed from the registry disappears from the card on the next beat.
- **Unreachable is a reading.** A server that does not answer is published as
`reachable: false` with the reason; it never takes the heartbeat down.
- **Which entries:** marked `"engine": "tensorfold"`, or auto-detected from
their `/metrics`. See `config/models.README.md` in ide-agent-kit, including
how a keyed server names its key.
- **TensorFold's default port is 8080, and so is `INTENT_MODEL_ENDPOINT`'s.**
The served-model probe above may send one short generation request to a
loopback endpoint to prove what is loaded. On a machine where TensorFold
listens on 8080, set `INTENT_MODEL_GENERATE=0` so the heartbeat never asks it
for a token.

The payload, per device:

```json
"tensorfold": {
"schema": 1,
"checked_at": "2026-10-14T09:00:00.000Z",
"scope": "local",
"error": null,
"servers": [{
"id": "mb-tensorfold", "host": "localhost", "port": 8091, "local": true,
"source": "registry", "reachable": true, "reason": null,
"model": "NVIDIA-Nemotron-3.5-Lightning-30B-A3B-MLX-4bit", "warming": false,
"version": null, "backend": null, "device": null,
"memory": { "used_gb": 18.7, "peak_gb": 40.7, "budget_gb": 96.2 },
"requests": { "running": 0, "waiting": 0 },
"tokens": { "prompt_total": 0, "generation_total": 0 },
"tok_s": null, "tok_s_window_s": null, "decode_tok_s": null,
"draft": { "drafted": 0, "accepted": 0, "acceptance": null },
"ttft_p50_s": null, "kv_cache_usage": 0,
"living_weights": { "available": false, "facts": null, "learning": null, "learned": null },
"metrics": "ok",
"checked_at": "2026-10-14T09:00:00.000Z"
}]
}
```

A value that could not be read is `null`, never `0`. `error` is set (and
`servers` empty) when the registry itself could not be read, so "no servers"
and "could not tell" never look the same.

What the numbers mean:

- `local`: the server runs on this machine (loopback entry). `source` is
`registry` (marked `"engine": "tensorfold"`) or `detected`.
- `memory`: GB, base 10, one decimal, like `mem_total_gb`. `used` is the
engine's active memory, `budget` its configured budget.
- `tokens`: counters since the server started, for finished requests.
- `tok_s`: generated tokens of requests that finished between two reads,
divided by the seconds between them (`tok_s_window_s`). `null` on the first
read and after a server restart. `decode_tok_s` is the server's own live
decode rate, where the server reports one (the native build does).
- `draft.acceptance`: accepted over drafted MTP tokens since start, `null`
until something was drafted.
- `ttft_p50_s`: median time to first token since start, estimated from the
histogram buckets.
- `kv_cache_usage`: the fullest stream's cache use, 0 to 1.
- `living_weights.available`: `/v1/slide/graph` answered (`false` on a 404,
`null` when it could not be read). Counts only: the fact text stays on the
machine. The native build answers the route even without `--slide`, so
there `facts` is the better signal.
- `metrics`: `ok`, `auth_required`, `auth_rejected` or `unavailable`.
- `version`, `backend`, `device`: read from `/health` when the server reports
them (newer builds), `null` otherwise.

> Note: The older `examples/iak-integration.js` is a one-shot demo and will exit once its setInterval timer is unref'd. Use `uik-daemon` for long-running deployments.

## Quick start
Expand Down
Loading
Loading