Skip to content
Closed
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
10 changes: 8 additions & 2 deletions docs/harness-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,9 @@ the LAN, `http://192.168.1.50:9430`.
| `POST` | `/sessions/<id>/pin` | Pin/unpin. Body: `{pinned}`. |
| `GET` | `/sessions/<id>/rewind-preview/<userMessageId>` · `POST /sessions/<id>/rewind` | Rewind preview / rewind. Body: `{userMessageId, rewindTurnDiff?}`. |
| `GET` | `/skills` | Skill list. Query: `agent`, `keyword`, `workspaceDir`. |
| `POST` | `/sessions/<id>/skill-policy` | Set Session Skill dispositions. Body: `{dispositions?:{<name>:"mandatory"|"optional"|"forbidden"|null}, closed?:boolean}`. Returns the Session (with `skillPolicy`). |
| `GET` | `/skills/proposals` | Skill/Memory drafts awaiting human review. Query: `status` (`pending`\|`approved`\|`rejected`\|`cancelled`), `kind` (`skill`\|`memory`), `sessionId`, `limit` (default 50, values above 200 clamped to 200). Returns `{proposals:[…]}` with each proposal's `id`, `kind`, `action`, `status`, `title`, `summary`, `draft`, `editedDraft?` and the provenance fields. |
| `POST` | `/skills/proposals/<id>/review` | Approve or reject a draft. Body: `{decision:"approve"\|"reject", editedDraft?, reviewNote?}`. Returns `{applied, title, status}`: `applied` is `true` when an approval wrote the draft, `false` when the decision was only recorded (a rejection). Reviewing an unknown proposal answers `404` and an already-reviewed one `409`, both leaving the stored proposal untouched. |
| `GET` | `/mcp` | MCP server list. Query: `keyword`, `sessionId`. |
| `GET` | `/status` | Runtime diagnostics, account status, permission mode, model roster. |
| `GET` | `/events` | Runtime event stream (`text/event-stream`): `questionnaire.ask`, `permission.ask`, `session.created`, `session.queue.updated`, … One subscription per connection; `: ping` keepalives every 15 s. |
Expand Down Expand Up @@ -390,8 +393,11 @@ permissions. Loopback does not hide the port from other users on the same
machine; the token file mode is what keeps those users out.
There is no per-turn subscription endpoint that replays a turn started by
another connection (`/events` carries Runtime events, not deltas), so a second
device joining a live turn polls the transcript until it settles. Skill writes
(create/edit/delete) are not exposed; `GET /skills` is read-only.
device joining a live turn polls the transcript until it settles. Direct Skill
file create/edit/delete is not exposed; idle Skill/Memory drafts land as
proposals under `/skills/proposals` and apply only after human
approve/reject via `/skills/proposals/<id>/review`. Per-session Skill
dispositions use `POST /sessions/<id>/skill-policy`.

## 4. Notes for callers

Expand Down
7 changes: 7 additions & 0 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,13 @@ Interactive control is `/skills` in the TUI:
Approval writes the draft only after the write succeeds. A failed write leaves
the proposal pending so it can be retried or rejected. Rejection never writes.

The session server exposes the same writes to remote clients:
`POST /sessions/<id>/skill-policy` sets one Session's dispositions, and
`GET /skills/proposals` plus `POST /skills/proposals/<id>/review` list and decide
pending drafts. Routes, query parameters, and status codes are in
[harness integration](harness-integration.md). Direct Skill file create, edit, and
delete remain unexposed over HTTP.

Each user turn may add a `<prompt-skill-match>` reminder for Skills whose name
or description overlaps the latest prompt, and a `<relevant-memory>` reminder
for short overlapping Memory excerpts. Forbidden and closed-catalog Skills are
Expand Down
192 changes: 192 additions & 0 deletions packages/tui/src/server/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const DEFAULT_TUI_SERVER_PORT = 8788;
const TUI_SERVER_LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
const MAX_SESSION_ID_SEGMENT_LENGTH = 256;
const MAX_JSON_BODY_BYTES = 4 * 1024 * 1024;
/** Mirrors the draft store's own cap (`local-runtime/src/knowledge/proposal-store.ts`). */
const MAX_KNOWLEDGE_PROPOSAL_LIMIT = 200;

export interface TuiServerLogger {
info(message: string): void;
Expand Down Expand Up @@ -204,6 +206,17 @@ async function handleTuiServerRequest(
return;
}

// Knowledge proposals (idle Skill/Memory drafts awaiting human review). The
// path shape is pinned here so a stray `/skills/<other>` keeps falling through
// to the 404 tail of this function, whatever capabilities the runtime exposes.
if (
segments[0] === 'skills' &&
segments[1] === 'proposals' &&
(segments.length === 2 || (segments.length === 4 && segments[3] === 'review'))
) {
await handleSkillProposals(runtime, method, segments, url, request, response);
return;
}
if (segments[0] === 'skills' && segments.length === 1) {
await handleTopLevelList(runtime, method, url, response, 'listSkills');
return;
Expand Down Expand Up @@ -694,6 +707,37 @@ async function handleTuiServerRequest(
return;
}

// /sessions/:id/skill-policy — set mandatory/optional/forbidden Skill dispositions.
if (sub === 'skill-policy' && segments.length === 3 && method === 'POST') {
if (!runtime.updateSessionSkillPolicy) {
sendJson(response, 404, { error: 'skill policy is not supported by this runtime' });
return;
}
await handleAction(response, async () => {
const body = (await readJsonBody(request)) as {
dispositions?: unknown;
closed?: unknown;
};
const dispositions = parseSkillPolicyDispositions(body.dispositions);
const closed =
body.closed === undefined
? undefined
: typeof body.closed === 'boolean'
? body.closed
: (() => {
throw new InvalidTuiServerBodyError('closed must be a boolean');
})();
if (dispositions === undefined && closed === undefined) {
throw new InvalidTuiServerBodyError('dispositions or closed is required');
}
return runtime.updateSessionSkillPolicy!(sessionId, {
...(dispositions !== undefined ? { dispositions } : {}),
...(closed !== undefined ? { closed } : {}),
});
});
return;
}

// /sessions/:id/rewind + /sessions/:id/rewind-preview/:userMessageId
if (sub === 'rewind' && segments.length === 3 && method === 'POST' && runtime.rewindSession) {
await handleAction(response, async () => {
Expand Down Expand Up @@ -752,6 +796,121 @@ async function handleTuiServerRequest(
sendJson(response, 404, { error: 'not found' });
}

// ---------------------------------------------------------------------------
// Skill policy + knowledge proposals (kinetick-code #99)
// ---------------------------------------------------------------------------

async function handleSkillProposals(
runtime: TuiServerRuntime,
method: string,
segments: string[],
url: URL,
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
// GET /skills/proposals
if (segments.length === 2) {
if (method !== 'GET' && method !== 'HEAD') {
sendJson(response, 405, { error: 'method not allowed' });
return;
}
if (!runtime.listKnowledgeProposals) {
sendJson(response, 404, { error: 'knowledge proposals are not supported by this runtime' });
return;
}
await sendRuntimeJson(response, async () => {
const status = url.searchParams.get('status') ?? undefined;
const kind = url.searchParams.get('kind') ?? undefined;
const sessionId = url.searchParams.get('sessionId') ?? undefined;
const limit = clampKnowledgeProposalLimit(url.searchParams.get('limit'));
if (
status !== undefined &&
status !== 'pending' &&
status !== 'approved' &&
status !== 'rejected' &&
status !== 'cancelled'
) {
throw new InvalidTuiServerQueryError(`invalid status: ${status}`);
}
if (kind !== undefined && kind !== 'skill' && kind !== 'memory') {
throw new InvalidTuiServerQueryError(`invalid kind: ${kind}`);
}
const proposals = await runtime.listKnowledgeProposals!({
...(status
? { status: status as 'pending' | 'approved' | 'rejected' | 'cancelled' }
: {}),
...(kind ? { kind: kind as 'skill' | 'memory' } : {}),
...(sessionId ? { sessionId } : {}),
...(limit !== undefined ? { limit } : {}),
});
return { proposals };
});
return;
}

// POST /skills/proposals/:id/review
if (segments.length === 4 && segments[3] === 'review') {
if (method !== 'POST') {
sendJson(response, 405, { error: 'method not allowed' });
return;
}
if (!runtime.reviewKnowledgeProposal) {
sendJson(response, 404, { error: 'knowledge proposals are not supported by this runtime' });
return;
}
const proposalId = decodeURIComponent(segments[2]!);
await handleAction(response, async () => {
const body = (await readJsonBody(request)) as {
decision?: unknown;
editedDraft?: unknown;
reviewNote?: unknown;
};
if (body.decision !== 'approve' && body.decision !== 'reject') {
throw new InvalidTuiServerBodyError('decision must be approve | reject');
}
if (body.editedDraft !== undefined && typeof body.editedDraft !== 'string') {
throw new InvalidTuiServerBodyError('editedDraft must be a string');
}
if (body.reviewNote !== undefined && typeof body.reviewNote !== 'string') {
throw new InvalidTuiServerBodyError('reviewNote must be a string');
}
return runtime.reviewKnowledgeProposal!({
proposalId,
decision: body.decision,
...(typeof body.editedDraft === 'string' ? { editedDraft: body.editedDraft } : {}),
...(typeof body.reviewNote === 'string' ? { reviewNote: body.reviewNote } : {}),
});
});
return;
}

sendJson(response, 404, { error: 'not found' });
}

function parseSkillPolicyDispositions(
value: unknown,
): Record<string, 'mandatory' | 'optional' | 'forbidden' | null> | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidTuiServerBodyError('dispositions must be an object');
}
const next: Record<string, 'mandatory' | 'optional' | 'forbidden' | null> = {};
for (const [name, disposition] of Object.entries(value as Record<string, unknown>)) {
if (
disposition !== null &&
disposition !== 'mandatory' &&
disposition !== 'optional' &&
disposition !== 'forbidden'
) {
throw new InvalidTuiServerBodyError(
`disposition for ${name} must be mandatory | optional | forbidden | null`,
);
}
next[name] = disposition;
}
return next;
}

// ---------------------------------------------------------------------------
// Top-level resources (skills, MCP, status)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -984,10 +1143,32 @@ function toHttpStatus(error: unknown): number {
return status;
}
}
const domainStatus = knowledgeProposalHttpStatus(error);
if (domainStatus !== undefined) return domainStatus;
if (isSessionLookupMiss(error)) return 404;
return 500;
}

/**
* The knowledge-proposal domain reports failures with a `code`, not a transport
* `status` (`local-runtime/src/knowledge/proposal-store.ts`), so its errors
* reach this boundary as a bare 500 — and the review route is exactly where a
* client retries: a proposal that is gone (404) must stay distinguishable from
* one that was already reviewed (409), and from the server actually failing
* (500, which stays retryable because the decision is recorded only after the
* draft applied).
*
* Matched by name rather than `instanceof`: the class lives in `local-runtime`,
* which this package does not depend on.
*/
function knowledgeProposalHttpStatus(error: unknown): number | undefined {
if (!(error instanceof Error) || error.name !== 'KnowledgeProposalError') return undefined;
const code = (error as { readonly code?: unknown }).code;
if (code === 'not-found') return 404;
if (code === 'not-pending') return 409;
return undefined;
}

function isSessionLookupMiss(error: unknown): boolean {
return (
error instanceof Error && /(?:did not return session|session not found)/iu.test(error.message)
Expand Down Expand Up @@ -1031,6 +1212,17 @@ function parseLimitQuery(value: string | null): number | undefined {
return limit;
}

/**
* `parseLimitQuery` bounds the low end only, which is fine for paged reads of a
* bounded collection. The draft store keeps every past proposal, so the query
* string needs a ceiling too: an unclamped `?limit=100000` is a request to
* serialize the whole store over the LAN.
*/
function clampKnowledgeProposalLimit(value: string | null): number | undefined {
const limit = parseLimitQuery(value);
return limit === undefined ? undefined : Math.min(limit, MAX_KNOWLEDGE_PROPOSAL_LIMIT);
}

function parseBooleanQuery(query: URLSearchParams, key: string): boolean | undefined {
const value = query.get(key);
if (value === null) return undefined;
Expand Down
4 changes: 4 additions & 0 deletions packages/tui/src/server/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,12 @@ export type TuiServerRuntime = Pick<
| 'getSessionUsage'
| 'requestCompaction'
| 'listSkills'
| 'listKnowledgeProposals'
| 'reviewKnowledgeProposal'
| 'listMcpServers'
| 'getContextSnapshot'
// Session skill policy (kinetick-code #99).
| 'updateSessionSkillPolicy'
// Configuration (models, permission mode, diagnostics, account).
| 'getRuntimeDiagnostics'
| 'getAccountStatus'
Expand Down
Loading
Loading