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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Release executables are currently unsigned. Windows SmartScreen may warn before
3. Verify the archive:

```powershell
$zip = ".\zcode-extensions-v0.3.4-windows-x64.zip"
$zip = ".\zcode-extensions-v0.3.5-windows-x64.zip"
$expected = (Get-Content "$zip.sha256").Split()[0].ToLowerInvariant()
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) { throw "Checksum mismatch" }
Expand All @@ -52,7 +52,7 @@ Release executables are currently unsigned. Windows SmartScreen may warn before
4. Extract the archive to a permanent location. The ZIP contains a stable `zcode-extensions` directory:

```powershell
Expand-Archive .\zcode-extensions-v0.3.4-windows-x64.zip -DestinationPath D:\
Expand-Archive .\zcode-extensions-v0.3.5-windows-x64.zip -DestinationPath D:\
Set-Location D:\zcode-extensions
```

Expand Down Expand Up @@ -110,7 +110,7 @@ Queued updates replace only the extension bundle on startup. The previous bundle
Close ZCode, download the new release, and extract it over the same parent directory:

```powershell
Expand-Archive .\zcode-extensions-v0.3.4-windows-x64.zip -DestinationPath D:\ -Force
Expand-Archive .\zcode-extensions-v0.3.5-windows-x64.zip -DestinationPath D:\ -Force
Set-Location D:\zcode-extensions
.\bin\zdp.exe repair
.\bin\zdp.exe launch
Expand Down Expand Up @@ -181,7 +181,7 @@ bun run build:example
bun run build
bun run build:sdk
bun run pack:sdk
bun run release:package -- --tag v0.3.4
bun run release:package -- --tag v0.3.5
```

See [Developing extensions](docs/extension-development.md) for the public API and [Hello Extension](examples/hello-extension) for a complete minimal project.
Expand Down
4 changes: 2 additions & 2 deletions docs/extension-development.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Developing ZCode Desktop Extensions

This guide describes extension API version 1 as implemented by host/SDK 0.3.4. The public package is [`@notmike101/zcode-extension-sdk`](https://www.npmjs.com/package/@notmike101/zcode-extension-sdk), the source contract is [`sdk/index.ts`](../sdk/index.ts), and [Hello Extension](../examples/hello-extension) remains a complete legacy-lifecycle example.
This guide describes extension API version 1 as implemented by host/SDK 0.3.5. The public package is [`@notmike101/zcode-extension-sdk`](https://www.npmjs.com/package/@notmike101/zcode-extension-sdk), the source contract is [`sdk/index.ts`](../sdk/index.ts), and [Hello Extension](../examples/hello-extension) remains a complete legacy-lifecycle example.

Extensions are trusted local code. A main entrypoint runs in ZCode's Electron main process with Node.js access, while an optional renderer entrypoint runs inside the ZCode renderer. Declared capabilities control access through the SDK and are shown during installation; they do not sandbox trusted Node or renderer code.

Expand All @@ -9,7 +9,7 @@ Extensions are trusted local code. A main entrypoint runs in ZCode's Electron ma
For a separate Bun or TypeScript project:

```powershell
bun add -d @notmike101/zcode-extension-sdk@0.3.4
bun add -d @notmike101/zcode-extension-sdk@0.3.5
```

Import main-only types and helpers from `@notmike101/zcode-extension-sdk/main`, browser-safe renderer types and helpers from `/renderer`, and unstable raw-channel types from `/experimental`. The root export is also browser-safe. A JSON Schema is available at `/manifest.schema.json`, and `validateExtensionManifest` or `assertExtensionManifest` can validate manifests at runtime.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-desktop-extensions",
"version": "0.3.4",
"version": "0.3.5",
"description": "An update-resistant extension host for the ZCode Electron desktop application.",
"private": true,
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-sdk-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const packageJson = JSON.parse(await readFile(path.join(sdk, "package.json"), "u
exports: Record<string, unknown>;
};

if (packageJson.name !== "@notmike101/zcode-extension-sdk" || packageJson.version !== "0.3.4") {
if (packageJson.name !== "@notmike101/zcode-extension-sdk" || packageJson.version !== "0.3.5") {
throw new Error("Unexpected SDK package identity");
}
for (const entry of [".", "./main", "./renderer", "./experimental", "./manifest.schema.json"]) {
Expand Down
2 changes: 1 addition & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@notmike101/zcode-extension-sdk",
"version": "0.3.4",
"version": "0.3.5",
"description": "Public TypeScript SDK and authoring helpers for ZCode Desktop Extensions.",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion src/host/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const zcodeGateway = new ZCodeGateway({
emit("host-state-changed");
},
});
const taskService = new TaskService({gateway: zcodeGateway, logger});
const taskService = new TaskService({gateway: zcodeGateway, logger, zcodeVersion});
const zcodeService = new ExtensionZCodeService({gateway: zcodeGateway, taskService, hostVersion: HOST_VERSION, zcodeVersion});

function emit(event: string, payload?: unknown): void {
Expand Down
7 changes: 6 additions & 1 deletion src/protocol/desktop-service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {randomUUID} from "node:crypto";
import {readdir, readFile} from "node:fs/promises";
import path from "node:path";
import {pathToFileURL} from "node:url";
Expand Down Expand Up @@ -93,7 +94,11 @@ export class DesktopServicePortBroker {
queueMicrotask(() => {
const {port1, port2} = new MessageChannelMain();
try {
originalPostMessage({type: "attach-service-port"}, [port2]);
originalPostMessage({
type: "attach-service-port",
attachmentId: `zdp-${randomUUID()}`,
clientMode: "desktop-continuous",
}, [port2]);
this.#attach({port: port1, process: child});
} catch (error) {
closePort(port1);
Expand Down
96 changes: 68 additions & 28 deletions src/protocol/task-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {randomUUID} from "node:crypto";
import {EventEmitter} from "node:events";
import path from "node:path";
import semver from "semver";
import type {JsonLogger} from "../shared/logger.ts";
import {taskSpecSchema, type TaskSpec} from "../shared/schemas.ts";
import type {
Expand Down Expand Up @@ -36,6 +37,7 @@ type TaskServiceOptions = {
vendorAsar?: string;
portBroker?: Pick<DesktopServicePortBroker, "current" | "onChange" | "waitForPort" | "dispose">;
logger: JsonLogger;
zcodeVersion?: string;
onHealth?: (status: "idle" | "starting" | "ready" | "error", error?: string) => void;
connect?: (port: DesktopServicePort["port"], vendorAsar: string) => Promise<DesktopServiceConnection>;
};
Expand All @@ -61,6 +63,7 @@ type TaskIndexService = {
createTask: (input: Record<string, unknown>) => Promise<unknown>;
getTaskMeta: (input: Record<string, unknown>) => Promise<unknown>;
renameTask: (input: Record<string, unknown>) => Promise<unknown>;
sendPrompt: (input: Record<string, unknown>) => Promise<unknown>;
};

type BroadcastService = {
Expand Down Expand Up @@ -141,30 +144,39 @@ export class TaskService extends EventEmitter {
const spec = taskSpecSchema.parse(input);
const {broadcast, session, task} = await this.#services();
const target = workspaceTarget(spec.workspacePath);
const useV4TaskFacade = supportsV4TaskFacade(this.#options.zcodeVersion);
await this.#options.logger.info("Creating native ZCode task", {
workspacePath: spec.workspacePath,
zcodeVersion: this.#options.zcodeVersion,
bridge: useV4TaskFacade ? "task-facade-v4" : "legacy-session",
});
const createdTask = asRecord(await task.createTask({
...target,
mode: spec.mode,
...(useV4TaskFacade ? {v4Create: true} : {}),
...(spec.model ? {model: spec.model} : {}),
...(spec.thoughtLevel ? {thoughtLevel: spec.thoughtLevel} : {}),
...(spec.toolAllowlist ? {toolAllowlist: spec.toolAllowlist} : {}),
...(spec.toolDenylist ? {toolDenylist: spec.toolDenylist} : {}),
}));
const sessionId = stringValue(createdTask.taskId);
if (!sessionId) throw new Error("ZCode did not return a task ID");
await this.#options.logger.info("Native ZCode task created", {
sessionId,
bridge: useV4TaskFacade ? "task-facade-v4" : "legacy-session",
});

const inputId = randomUUID();
const traceId = stringValue(createdTask.traceId) ?? randomUUID();
const inputId = useV4TaskFacade ? traceId : randomUUID();
const queryId = randomUUID();
const messageId = randomUUID();
let resolveCompletion!: (result: TaskResult) => void;
const completion = new Promise<TaskResult>((resolve) => { resolveCompletion = resolve; });
let settled = false;

const subscriptionTarget = {
...target,
sessionId,
deliveryKind: "desktop-continuous",
includeSnapshot: true,
};
const subscriptionTarget = useV4TaskFacade
? {...target, taskId: sessionId, deliveryKind: "desktop-continuous"}
: {...target, sessionId, deliveryKind: "desktop-continuous", includeSnapshot: true};
const run = {} as ActiveRun;
const finish = (result: TaskResult) => {
if (settled) return;
Expand All @@ -187,8 +199,8 @@ export class TaskService extends EventEmitter {
interactionBlocked: false,
finish,
subscription: this.#gateway.subscribe(
"zcode-session",
"onDynamicSessionEvent",
useV4TaskFacade ? "zcode-task" : "zcode-session",
useV4TaskFacade ? "onDynamicTaskEvent" : "onDynamicSessionEvent",
subscriptionTarget,
(value) => this.#onSessionEvent(run, value),
),
Expand All @@ -200,18 +212,38 @@ export class TaskService extends EventEmitter {
? await this.#rename(task, target, sessionId, run.title) ?? createdTask
: createdTask;
await this.#broadcastTaskChange(broadcast, target, sessionId, "created", {task: visibleTask});
await session.sendPrompt({
...target,
sessionId,
inputId,
queryId,
messageId,
content: spec.prompt,
});
if (run.title) await this.#rename(task, target, sessionId, run.title);
await this.#broadcastTaskChange(broadcast, target, sessionId, "prompt_sent", {
if (useV4TaskFacade) {
await this.#options.logger.info("Submitting prompt through ZCode task facade", {sessionId, traceId});
await task.sendPrompt({
taskId: sessionId,
traceId,
queryId,
messageId,
content: spec.prompt,
});
await this.#options.logger.info("ZCode task facade accepted prompt", {sessionId, traceId});
} else {
await session.sendPrompt({
...target,
sessionId,
inputId,
queryId,
messageId,
content: spec.prompt,
});
}
const promptSent = () => this.#broadcastTaskChange(broadcast, target, sessionId, "prompt_sent", {
prompt: {content: spec.prompt, messageId, sentAt: Date.now()},
});
if (useV4TaskFacade) {
// ZCode 3.4.2 may leave follow-up task metadata calls pending after the
// task facade accepts a prompt. The run is active at this point, so do
// not keep extension callers (and their UI) waiting on notifications.
void promptSent();
} else {
if (run.title) await this.#rename(task, target, sessionId, run.title);
await promptSent();
}
} catch (error) {
this.#runs.delete(sessionId);
run.subscription.dispose();
Expand Down Expand Up @@ -287,31 +319,35 @@ export class TaskService extends EventEmitter {
run.interactionBlocked = true;
return;
}
if (envelopeType !== "session.event") return;
const event = asRecord(envelope.event);
const event = envelopeType === "session.event" ? asRecord(envelope.event) : envelope;
const eventType = stringValue(event.type);
if (!eventType) return;
const payload = asRecord(event.payload);
const eventInputId = stringValue(payload.inputId);
const eventInputId = stringValue(payload.inputId) ?? stringValue(event.inputId);
if (eventInputId && eventInputId !== run.inputId) return;
if (eventType === "permission.requested" || eventType === "elicitation.requested") {
if (eventType === "permission.requested" || eventType === "elicitation.requested"
|| eventType === "permission_request" || eventType === "elicitation_request") {
run.interactionBlocked = true;
return;
}
if (eventType === "session.titleUpdated" && run.title) {
void this.#services().then(({task}) => this.#rename(task, run.target, run.sessionId, run.title!));
return;
}
if (eventType === "turn.completed") {
const resultType = stringValue(payload.resultType) ?? "success";
if (eventType === "turn.completed" || eventType === "task_complete") {
const resultType = stringValue(payload.resultType) ?? stringValue(event.stopReason) ?? "success";
if (resultType === "cancelled") run.finish({sessionId: run.sessionId, status: "cancelled"});
else if (resultType !== "success") run.finish({sessionId: run.sessionId, status: "failed", error: resultType});
else if (resultType !== "success" && resultType !== "complete") {
run.finish({sessionId: run.sessionId, status: "failed", error: resultType});
}
else run.finish({sessionId: run.sessionId, status: run.interactionBlocked ? "needs_attention" : "succeeded"});
} else if (eventType === "turn.failed") {
} else if (eventType === "turn.failed" || eventType === "task_error") {
const error = asRecord(payload.error);
run.finish({
sessionId: run.sessionId,
status: run.interactionBlocked ? "needs_attention" : "failed",
error: stringValue(error.message) ?? stringValue(error.detail) ?? "ZCode session turn failed",
error: stringValue(error.message) ?? stringValue(error.detail)
?? stringValue(event.error) ?? stringValue(event.detail) ?? "ZCode session turn failed",
});
}
}
Expand Down Expand Up @@ -374,3 +410,7 @@ function asRecord(value: unknown): Record<string, unknown> {
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

function supportsV4TaskFacade(version: string | undefined): boolean {
return Boolean(version && semver.valid(version) && semver.gte(version, "3.4.2"));
}
2 changes: 1 addition & 1 deletion src/shared/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from "node:path";

export const HOST_NAME = "ZCode Desktop Extensions";
export const HOST_VERSION = "0.3.4";
export const HOST_VERSION = "0.3.5";
export const API_VERSION = 1;
export const INSTALL_STATE_VERSION = 1;
export const DEFAULT_ZCODE_ROOT = path.join(
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/serve-renderer-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const server = Bun.serve({
});
}
if (url.pathname === "/renderer/index.js") {
return new Response(await readFile(path.join(root, "runtime", "versions", "0.3.4", "renderer", "index.js")), {
return new Response(await readFile(path.join(root, "runtime", "versions", "0.3.5", "renderer", "index.js")), {
headers: {"content-type": "text/javascript; charset=utf-8"},
});
}
Expand Down
Loading