plumb-bridge 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/package.json +17 -3
- package/src/adapters/claude.ts +21 -62
- package/src/adapters/cursor.ts +213 -0
- package/src/adapters/detect.ts +27 -0
- package/src/adapters/echo.ts +5 -23
- package/src/adapters/generic.ts +5 -14
- package/src/adapters/opencode.ts +11 -59
- package/src/adapters/pi.ts +40 -66
- package/src/adapters/registry.ts +24 -1
- package/src/adapters/stream-json.ts +89 -0
- package/src/adapters/venom.ts +78 -0
- package/src/adapters/wolfy.ts +94 -0
- package/src/cli.ts +215 -10
- package/src/config.test.ts +170 -0
- package/src/config.ts +178 -0
- package/src/core/executor.ts +113 -77
- package/src/core/ledger.ts +15 -10
- package/src/core/log.ts +12 -0
- package/src/core/process.ts +193 -10
- package/src/core/server.ts +38 -7
- package/src/core/session-store.ts +158 -0
- package/src/core/task-store.ts +137 -0
- package/src/types.ts +30 -1
- package/test/adapter-parse.test.ts +328 -0
- package/test/persistent-process.test.ts +56 -0
- package/test/rpc.test.ts +57 -0
- package/test/session-store.test.ts +129 -0
- package/test/task-store.test.ts +95 -0
- package/tsconfig.json +1 -1
package/src/core/server.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// PLUMB — Server
|
|
2
2
|
// Express + @a2a-js/sdk. Agent Card. JSON-RPC. REST. Health.
|
|
3
|
-
//
|
|
3
|
+
// Derived from prior bridge layout: Plumb naming, ledger injection, no vendor IDE coupling.
|
|
4
4
|
|
|
5
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
5
6
|
import type { Request, Response, NextFunction } from 'express';
|
|
6
7
|
import express from 'express';
|
|
7
|
-
import { DefaultRequestHandler
|
|
8
|
+
import { DefaultRequestHandler } from '@a2a-js/sdk/server';
|
|
8
9
|
import {
|
|
9
10
|
agentCardHandler,
|
|
10
11
|
jsonRpcHandler,
|
|
@@ -14,6 +15,20 @@ import {
|
|
|
14
15
|
import type { AgentAdapter, PlumbConfig } from '../types.ts';
|
|
15
16
|
import { Ledger } from './ledger.ts';
|
|
16
17
|
import { PlumbExecutor } from './executor.ts';
|
|
18
|
+
import { PlumbTaskStore } from './task-store.ts';
|
|
19
|
+
|
|
20
|
+
let _pkgVersion: string | undefined;
|
|
21
|
+
function getPackageVersion(): string {
|
|
22
|
+
if (_pkgVersion !== undefined) return _pkgVersion;
|
|
23
|
+
try {
|
|
24
|
+
const { readFileSync } = require('node:fs');
|
|
25
|
+
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
|
|
26
|
+
_pkgVersion = pkg.version ?? '0.0.0';
|
|
27
|
+
} catch {
|
|
28
|
+
_pkgVersion = '0.0.0';
|
|
29
|
+
}
|
|
30
|
+
return _pkgVersion!;
|
|
31
|
+
}
|
|
17
32
|
|
|
18
33
|
export function createPlumbServer(config: PlumbConfig & { adapter: AgentAdapter }) {
|
|
19
34
|
const { adapter, port } = config;
|
|
@@ -24,7 +39,7 @@ export function createPlumbServer(config: PlumbConfig & { adapter: AgentAdapter
|
|
|
24
39
|
name,
|
|
25
40
|
description: `${adapter.displayName} via plumb — A2A bridge`,
|
|
26
41
|
protocolVersion: '0.3.0',
|
|
27
|
-
version:
|
|
42
|
+
version: getPackageVersion(),
|
|
28
43
|
url: process.env.PLUMB_PUBLIC_URL ?? `http://localhost:${port}`,
|
|
29
44
|
capabilities: { streaming: true },
|
|
30
45
|
skills: adapter.skills.map(s => ({ ...s, description: s.name })),
|
|
@@ -39,13 +54,21 @@ export function createPlumbServer(config: PlumbConfig & { adapter: AgentAdapter
|
|
|
39
54
|
};
|
|
40
55
|
|
|
41
56
|
const executor = new PlumbExecutor(adapter, config, ledger);
|
|
42
|
-
const taskStore = new
|
|
57
|
+
const taskStore = new PlumbTaskStore({ maxTasks: 100, completedRetentionMinutes: 60 });
|
|
43
58
|
const requestHandler = new DefaultRequestHandler(agentCard, taskStore, executor);
|
|
44
59
|
|
|
60
|
+
// Periodic cleanup: evict terminal tasks past retention window every 5 min
|
|
61
|
+
const cleanupInterval = setInterval(() => {
|
|
62
|
+
taskStore.cleanupStaleCompleted();
|
|
63
|
+
}, 5 * 60 * 1000);
|
|
64
|
+
// Don't hold the process open
|
|
65
|
+
if (cleanupInterval.unref) cleanupInterval.unref();
|
|
66
|
+
|
|
45
67
|
return {
|
|
46
68
|
executor,
|
|
47
69
|
agentCard,
|
|
48
70
|
ledger,
|
|
71
|
+
taskStore,
|
|
49
72
|
setupApp: (app: express.Express) => {
|
|
50
73
|
app.use(express.json({ limit: '10mb' }));
|
|
51
74
|
|
|
@@ -55,20 +78,28 @@ export function createPlumbServer(config: PlumbConfig & { adapter: AgentAdapter
|
|
|
55
78
|
res.redirect('/.well-known/agent-card.json');
|
|
56
79
|
});
|
|
57
80
|
app.get('/health', (_req: Request, res: Response) => {
|
|
58
|
-
|
|
81
|
+
const health: Record<string, unknown> = {
|
|
59
82
|
status: 'ok',
|
|
60
83
|
agent: name,
|
|
61
84
|
adapter: adapter.id,
|
|
62
85
|
mode: adapter.mode,
|
|
63
86
|
tier: adapter.tier,
|
|
64
87
|
ledger: ledger.getPath(),
|
|
65
|
-
}
|
|
88
|
+
};
|
|
89
|
+
const alive = executor.isPersistentAlive();
|
|
90
|
+
if (alive !== null) {
|
|
91
|
+
health.agentAlive = alive;
|
|
92
|
+
}
|
|
93
|
+
res.json(health);
|
|
66
94
|
});
|
|
67
95
|
|
|
68
96
|
// Auth gate — protects A2A endpoints if apiKey is configured
|
|
69
97
|
if (config.apiKey) {
|
|
98
|
+
const expected = Buffer.from(`Bearer ${config.apiKey}`);
|
|
70
99
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
71
|
-
|
|
100
|
+
const header = req.headers.authorization ?? '';
|
|
101
|
+
const given = Buffer.from(header);
|
|
102
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
|
|
72
103
|
return res.status(401).json({ error: { message: 'Unauthorized' } });
|
|
73
104
|
}
|
|
74
105
|
next();
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// PLUMB — Cursor Session Store
|
|
2
|
+
// In-memory session tracking for multi-turn Cursor conversations.
|
|
3
|
+
// Ported from fangai cursor-adapter.ts. Tracks session_ids from
|
|
4
|
+
// cursor-agent's --continue/--resume lifecycle.
|
|
5
|
+
//
|
|
6
|
+
// When sessionTtlMs is set, stale server sessions are abandoned (no --continue)
|
|
7
|
+
// and a cold recap of the last N turns is injected into the next prompt so cold
|
|
8
|
+
// starts maintain continuity without relying on Cursor holding the session.
|
|
9
|
+
|
|
10
|
+
export interface SessionTurn {
|
|
11
|
+
user: string;
|
|
12
|
+
assistant: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CursorSession {
|
|
16
|
+
id: string;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
lastUsedAt: Date;
|
|
19
|
+
workspace: string;
|
|
20
|
+
model: string;
|
|
21
|
+
turnCount: number;
|
|
22
|
+
/** Last N completed turns for recap after --continue is unsafe. */
|
|
23
|
+
turns: SessionTurn[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CursorSessionStoreOptions {
|
|
27
|
+
/** Max age since lastUsedAt before dropping session and injecting recap. null = no TTL. */
|
|
28
|
+
sessionTtlMs?: number | null;
|
|
29
|
+
/** Max completed turns to retain per session and include in recap. Default 8. */
|
|
30
|
+
recapMaxTurns?: number;
|
|
31
|
+
/** Max chars per turn leg stored/echoed in recap. Default 8000. */
|
|
32
|
+
recapMaxCharsPerLeg?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class CursorSessionStore {
|
|
36
|
+
private sessions = new Map<string, CursorSession>();
|
|
37
|
+
private lastSessionId: string | null = null;
|
|
38
|
+
private readonly sessionTtlMs: number | null;
|
|
39
|
+
private readonly recapMaxTurns: number;
|
|
40
|
+
private readonly recapMaxCharsPerLeg: number;
|
|
41
|
+
/** Queued cold recap for the next formatInput after TTL expiry. */
|
|
42
|
+
private coldRecapPending: string | null = null;
|
|
43
|
+
|
|
44
|
+
constructor(opts: CursorSessionStoreOptions = {}) {
|
|
45
|
+
this.sessionTtlMs = opts.sessionTtlMs === undefined ? null : opts.sessionTtlMs;
|
|
46
|
+
this.recapMaxTurns = opts.recapMaxTurns ?? 8;
|
|
47
|
+
this.recapMaxCharsPerLeg = opts.recapMaxCharsPerLeg ?? 8000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Register a new session from cursor-agent output. */
|
|
51
|
+
register(sessionId: string, workspace: string, model: string): void {
|
|
52
|
+
const existing = this.sessions.get(sessionId);
|
|
53
|
+
if (existing) {
|
|
54
|
+
existing.lastUsedAt = new Date();
|
|
55
|
+
existing.turnCount++;
|
|
56
|
+
} else {
|
|
57
|
+
this.sessions.set(sessionId, {
|
|
58
|
+
id: sessionId,
|
|
59
|
+
createdAt: new Date(),
|
|
60
|
+
lastUsedAt: new Date(),
|
|
61
|
+
workspace,
|
|
62
|
+
model,
|
|
63
|
+
turnCount: 1,
|
|
64
|
+
turns: [],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
this.lastSessionId = sessionId;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* If the current lastSession is older than sessionTtlMs, remove it,
|
|
72
|
+
* queue a cold recap for the next prompt, and clear lastSession so
|
|
73
|
+
* the next spawn is fresh (no --continue).
|
|
74
|
+
*/
|
|
75
|
+
expireLastSessionIfStale(nowMs: number = Date.now()): void {
|
|
76
|
+
if (this.sessionTtlMs === null || this.lastSessionId === null) return;
|
|
77
|
+
const s = this.sessions.get(this.lastSessionId);
|
|
78
|
+
if (!s) {
|
|
79
|
+
this.lastSessionId = null;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (nowMs - s.lastUsedAt.getTime() <= this.sessionTtlMs) return;
|
|
83
|
+
|
|
84
|
+
const recap = this.buildRecapBlock(s.turns);
|
|
85
|
+
if (recap) this.coldRecapPending = recap;
|
|
86
|
+
|
|
87
|
+
this.sessions.delete(this.lastSessionId);
|
|
88
|
+
this.lastSessionId = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Pop the cold recap block (from TTL expiry) once, for the next user message. */
|
|
92
|
+
consumeColdRecap(): string | null {
|
|
93
|
+
const r = this.coldRecapPending;
|
|
94
|
+
this.coldRecapPending = null;
|
|
95
|
+
return r;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Append a completed turn after a successful result event. */
|
|
99
|
+
recordCompletedTurn(sessionId: string | null, user: string, assistant: string): void {
|
|
100
|
+
if (!sessionId) return;
|
|
101
|
+
const s = this.sessions.get(sessionId);
|
|
102
|
+
if (!s) return;
|
|
103
|
+
s.turns.push({
|
|
104
|
+
user: this.truncateLeg(user),
|
|
105
|
+
assistant: this.truncateLeg(assistant.trim()),
|
|
106
|
+
});
|
|
107
|
+
while (s.turns.length > this.recapMaxTurns) s.turns.shift();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Get the last active session ID for --continue. */
|
|
111
|
+
get lastSession(): string | null {
|
|
112
|
+
return this.lastSessionId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Get a specific session by ID. */
|
|
116
|
+
get(sessionId: string): CursorSession | undefined {
|
|
117
|
+
return this.sessions.get(sessionId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** List all sessions (most recent first). */
|
|
121
|
+
list(): CursorSession[] {
|
|
122
|
+
return Array.from(this.sessions.values()).sort(
|
|
123
|
+
(a, b) => b.lastUsedAt.getTime() - a.lastUsedAt.getTime(),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Clear all sessions. */
|
|
128
|
+
clear(): void {
|
|
129
|
+
this.sessions.clear();
|
|
130
|
+
this.lastSessionId = null;
|
|
131
|
+
this.coldRecapPending = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private truncateLeg(text: string): string {
|
|
135
|
+
const t = text.trim();
|
|
136
|
+
if (t.length <= this.recapMaxCharsPerLeg) return t;
|
|
137
|
+
return `${t.slice(0, this.recapMaxCharsPerLeg)}\n...[truncated]`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private buildRecapBlock(turns: SessionTurn[]): string | null {
|
|
141
|
+
if (!turns.length) return null;
|
|
142
|
+
const lines: string[] = [
|
|
143
|
+
'[Plumb: Prior Cursor session expired. Continuity recap — answer the new message after the separator.]',
|
|
144
|
+
'',
|
|
145
|
+
'### Earlier turns (recap)',
|
|
146
|
+
];
|
|
147
|
+
const slice = turns.slice(-this.recapMaxTurns);
|
|
148
|
+
for (let i = 0; i < slice.length; i++) {
|
|
149
|
+
const t = slice[i]!;
|
|
150
|
+
lines.push(`${i + 1}. **User:** ${t.user}`);
|
|
151
|
+
lines.push(` **Assistant:** ${t.assistant}`);
|
|
152
|
+
lines.push('');
|
|
153
|
+
}
|
|
154
|
+
lines.push('---');
|
|
155
|
+
lines.push('');
|
|
156
|
+
return lines.join('\n');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// PLUMB — Task Store
|
|
2
|
+
// LRU-bounded + TTL-cleanup task store. Replaces SDK InMemoryTaskStore.
|
|
3
|
+
// Prevents unbounded memory growth: evicts terminal tasks after retention
|
|
4
|
+
// window, enforces max task cap. Based on FangTaskStore (fangai, tested).
|
|
5
|
+
|
|
6
|
+
import type { Task } from '@a2a-js/sdk';
|
|
7
|
+
import type { TaskStore, ServerCallContext } from '@a2a-js/sdk/server';
|
|
8
|
+
|
|
9
|
+
const TERMINAL_STATES = new Set<string>([
|
|
10
|
+
'completed',
|
|
11
|
+
'failed',
|
|
12
|
+
'canceled',
|
|
13
|
+
'rejected',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
interface StoredEntry {
|
|
17
|
+
task: Task;
|
|
18
|
+
/** Timestamp when this task entered a terminal state (ms). null = active. */
|
|
19
|
+
terminalSinceMs: number | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function shallowClone(task: Task): Task {
|
|
23
|
+
const t: Task = { ...task };
|
|
24
|
+
if (t.history !== undefined) {
|
|
25
|
+
t.history = [...t.history];
|
|
26
|
+
}
|
|
27
|
+
if (t.artifacts !== undefined) {
|
|
28
|
+
t.artifacts = t.artifacts.map(a => ({ ...a }));
|
|
29
|
+
}
|
|
30
|
+
return t;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PlumbTaskStoreOptions {
|
|
34
|
+
/** Maximum tasks retained (LRU eviction). Default 100. */
|
|
35
|
+
maxTasks?: number;
|
|
36
|
+
/** Drop terminal tasks older than this many minutes. Default 60. */
|
|
37
|
+
completedRetentionMinutes?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class PlumbTaskStore implements TaskStore {
|
|
41
|
+
private readonly maxTasks: number;
|
|
42
|
+
private readonly retentionMs: number;
|
|
43
|
+
/** Map iteration order = LRU. Touch = delete + re-set. */
|
|
44
|
+
private readonly entries = new Map<string, StoredEntry>();
|
|
45
|
+
|
|
46
|
+
constructor(options?: PlumbTaskStoreOptions) {
|
|
47
|
+
const mt = options?.maxTasks ?? 100;
|
|
48
|
+
this.maxTasks = mt < 1 ? 1 : mt;
|
|
49
|
+
const mins = options?.completedRetentionMinutes ?? 60;
|
|
50
|
+
this.retentionMs = Math.max(1, mins) * 60 * 1000;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async save(task: Task, _context?: ServerCallContext): Promise<void> {
|
|
54
|
+
const cloned = shallowClone(task);
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const terminal = TERMINAL_STATES.has(cloned.status.state);
|
|
57
|
+
|
|
58
|
+
let terminalSinceMs: number | null = null;
|
|
59
|
+
if (terminal) {
|
|
60
|
+
const prev = this.entries.get(cloned.id);
|
|
61
|
+
if (prev && prev.terminalSinceMs !== null && TERMINAL_STATES.has(prev.task.status.state)) {
|
|
62
|
+
terminalSinceMs = prev.terminalSinceMs;
|
|
63
|
+
} else {
|
|
64
|
+
terminalSinceMs = now;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Touch: delete then re-set to maintain LRU order
|
|
69
|
+
if (this.entries.has(cloned.id)) {
|
|
70
|
+
this.entries.delete(cloned.id);
|
|
71
|
+
}
|
|
72
|
+
this.entries.set(cloned.id, { task: cloned, terminalSinceMs });
|
|
73
|
+
|
|
74
|
+
this.evictIfNeeded();
|
|
75
|
+
return Promise.resolve();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async load(taskId: string, _context?: ServerCallContext): Promise<Task | undefined> {
|
|
79
|
+
const entry = this.entries.get(taskId);
|
|
80
|
+
if (!entry) return Promise.resolve(undefined);
|
|
81
|
+
|
|
82
|
+
// Touch for LRU
|
|
83
|
+
this.entries.delete(taskId);
|
|
84
|
+
const touched: StoredEntry = {
|
|
85
|
+
task: entry.task,
|
|
86
|
+
terminalSinceMs: entry.terminalSinceMs,
|
|
87
|
+
};
|
|
88
|
+
this.entries.set(taskId, touched);
|
|
89
|
+
|
|
90
|
+
return Promise.resolve(shallowClone(entry.task));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Remove a single task by ID. */
|
|
94
|
+
delete(taskId: string, _context?: ServerCallContext): Promise<void> {
|
|
95
|
+
this.entries.delete(taskId);
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Drop terminal tasks past retention window. */
|
|
100
|
+
cleanupStaleCompleted(): void {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
for (const [id, entry] of this.entries) {
|
|
103
|
+
if (
|
|
104
|
+
TERMINAL_STATES.has(entry.task.status.state) &&
|
|
105
|
+
entry.terminalSinceMs !== null &&
|
|
106
|
+
now - entry.terminalSinceMs > this.retentionMs
|
|
107
|
+
) {
|
|
108
|
+
this.entries.delete(id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Number of tasks currently stored. */
|
|
114
|
+
get size(): number {
|
|
115
|
+
return this.entries.size;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private evictIfNeeded(): void {
|
|
119
|
+
while (this.entries.size > this.maxTasks) {
|
|
120
|
+
let victim: string | undefined;
|
|
121
|
+
// Evict terminal tasks first
|
|
122
|
+
for (const id of this.entries.keys()) {
|
|
123
|
+
const entry = this.entries.get(id);
|
|
124
|
+
if (entry && TERMINAL_STATES.has(entry.task.status.state)) {
|
|
125
|
+
victim = id;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// If no terminal tasks, evict oldest
|
|
130
|
+
if (victim === undefined) {
|
|
131
|
+
victim = this.entries.keys().next().value as string | undefined;
|
|
132
|
+
}
|
|
133
|
+
if (victim === undefined) break;
|
|
134
|
+
this.entries.delete(victim);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface AgentTask {
|
|
|
6
6
|
message: string;
|
|
7
7
|
context?: {
|
|
8
8
|
workdir?: string;
|
|
9
|
+
labels?: string[];
|
|
9
10
|
metadata?: Record<string, unknown>;
|
|
10
11
|
};
|
|
11
12
|
}
|
|
@@ -23,7 +24,6 @@ export interface PlumbConfig {
|
|
|
23
24
|
name?: string;
|
|
24
25
|
workdir?: string;
|
|
25
26
|
env?: Record<string, string>;
|
|
26
|
-
maxConcurrent?: number;
|
|
27
27
|
taskTimeout?: number;
|
|
28
28
|
killTimeout?: number;
|
|
29
29
|
apiKey?: string;
|
|
@@ -59,3 +59,32 @@ export type LedgerEvent =
|
|
|
59
59
|
| { type: 'task_completed'; taskId: string; timestamp: string }
|
|
60
60
|
| { type: 'task_failed'; taskId: string; error: string; timestamp: string }
|
|
61
61
|
| { type: 'task_cancelled'; taskId: string; timestamp: string };
|
|
62
|
+
|
|
63
|
+
// ─── Persistent RPC Types ────────────────────────────────────────────────────
|
|
64
|
+
// Correlated request/response over stdin/stdout for persistent agents (e.g. Pi).
|
|
65
|
+
|
|
66
|
+
/** Content shape for host_tool_result (subset of AgentToolResult). */
|
|
67
|
+
export type RpcHostToolResultContent = ReadonlyArray<Record<string, unknown>>;
|
|
68
|
+
|
|
69
|
+
/** Parsed RPC response from stdout { type: "response" }. */
|
|
70
|
+
export interface RpcParsedResponse {
|
|
71
|
+
command?: string;
|
|
72
|
+
success: boolean;
|
|
73
|
+
data?: unknown;
|
|
74
|
+
error?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Executes a host_tool_call from the persistent agent.
|
|
79
|
+
* Must return fragments suitable for { result: { content } }.
|
|
80
|
+
* abortSignal cooperatively cancels when agent emits host_tool_cancel.
|
|
81
|
+
*/
|
|
82
|
+
export type RpcHostToolExecutor = (
|
|
83
|
+
ctx: {
|
|
84
|
+
requestId: string;
|
|
85
|
+
toolCallId: string;
|
|
86
|
+
toolName: string;
|
|
87
|
+
args: Record<string, unknown>;
|
|
88
|
+
abortSignal: AbortSignal;
|
|
89
|
+
},
|
|
90
|
+
) => Promise<{ content: RpcHostToolResultContent; isError?: boolean }>;
|