feat: support inbound files across all channels

This commit is contained in:
xmanrui 2026-08-23 22:39:26 +08:00
parent 29bcb67a64
commit 0a26d7df92
45 changed files with 2651 additions and 343 deletions

View file

@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
import { isAbsolute } from 'node:path';
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
import {
appendInboundFilesToPrompt,
InboundFileError,
} from './inbound-file.mjs';
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
// Every channel plugin runs in the same Host process. Sharing ownership by
@ -466,6 +470,7 @@ export class HarnessClient {
#commandExecutor;
#controlExecutor;
#sessionMaintenanceExecutor;
#fileIngressExecutor;
#managedProcess = null;
#interactionRegistry;
#interactionOwnerships;
@ -486,6 +491,7 @@ export class HarnessClient {
commandExecutor,
controlExecutor,
sessionMaintenanceExecutor,
fileIngressExecutor,
}) {
if (typeof createWebSocket !== 'function') {
throw new TypeError('createWebSocket must be a function');
@ -509,6 +515,9 @@ export class HarnessClient {
&& typeof sessionMaintenanceExecutor !== 'function') {
throw new TypeError('sessionMaintenanceExecutor must be a function');
}
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
throw new TypeError('fileIngressExecutor must be a function');
}
this.#baseUrl = new URL(baseUrl);
this.#workspace = workspace;
// Keep an omitted preset absent so session.create resolves the Host's current default.
@ -523,6 +532,7 @@ export class HarnessClient {
this.#commandExecutor = commandExecutor;
this.#controlExecutor = controlExecutor;
this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
this.#fileIngressExecutor = fileIngressExecutor;
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
this.#interactionOwnerships = this.#interactionRegistry.ownerships;
this.#interactionClaims = this.#interactionRegistry.claims;
@ -1034,6 +1044,7 @@ export class HarnessClient {
? options.onInteractionResolved
: undefined;
const control = normalizeControl(options.control);
const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
await this.ensureRunning({ signal });
const before = await this.rpc(
'session.history',
@ -1075,6 +1086,9 @@ export class HarnessClient {
let interactionTask = null;
let artifactsDelivered = false;
let deliveredArtifactCount = 0;
let stagedInboundFiles = null;
let promptAccepted = false;
let turnFinished = false;
const deliverArtifacts = async () => {
if (!onArtifact || artifactsDelivered || tracker.turn === null) {
@ -1103,6 +1117,30 @@ export class HarnessClient {
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
try {
if (inboundFiles.length > 0) {
if (!this.#fileIngressExecutor) {
throw new InboundFileError(
'inbound-file-ingress-unavailable',
'Harness file ingress is unavailable in this Host process.',
);
}
const sessionList = await this.rpc(
'session.list',
{},
30_000,
{ signal },
);
const sessionWorkspace = sessionList?.items?.find(
(item) => item?.sessionId === sessionId,
)?.cwd;
stagedInboundFiles = await this.#fileIngressExecutor({
sessionId,
workspace: sessionWorkspace,
files: inboundFiles,
signal,
});
prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
}
if (interactionSignal) {
let markOpen;
const opened = new Promise((resolve) => { markOpen = resolve; });
@ -1134,6 +1172,7 @@ export class HarnessClient {
content,
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}, 30_000, { rpcId: promptRpcId, signal });
promptAccepted = true;
try {
const deadline = Date.now() + timeoutMs;
@ -1159,6 +1198,7 @@ export class HarnessClient {
}
}
if (!tracker.finished) continue;
turnFinished = true;
// An accepted /stop revokes attachment delivery even when Harness
// preserved a useful partial text answer for the existing UX.
const artifactCount = ownership?.stopRequested
@ -1185,6 +1225,11 @@ export class HarnessClient {
throw turnStoppedError();
}
} finally {
if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
await stagedInboundFiles.cleanup().catch((error) => {
console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
});
}
closeArtifactConsumer();
if (ownership) {
this.#unregisterControlOwnership(ownership);