@ckanner/dsh-plugin-flywheel 0.0.0-stage → 0.28.0

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/dist/policy.js ADDED
@@ -0,0 +1,37 @@
1
+ // Loaded only in the independently owned SDK process, never in the desktop host.
2
+ // Public rc.5 Cordis services; the worker verifies this acknowledgement before prompt().
3
+ export const inject = ['sandboxPolicy', 'approval', 'shell'];
4
+
5
+ export async function apply(ctx, config) {
6
+ const { permissionMode, cwd } = config;
7
+ if (!['read-only', 'workspace-write'].includes(permissionMode) || typeof cwd !== 'string' ||
8
+ ctx.shell.sandboxMode === undefined) {
9
+ throw new Error('Flywheel requires a confined DSH shell and explicit workspace policy');
10
+ }
11
+ // This local no-op proves that the actual runner starts and reports full
12
+ // enforcement. Merely selecting a policy value is not readiness evidence.
13
+ const probe = await ctx.shell.execute(ctx.shell.resolve({
14
+ command: ':', workdir: cwd, timeoutMs: 5000,
15
+ sandboxPolicy: { mode: permissionMode, workspaceRoot: cwd },
16
+ }));
17
+ const result = await probe.result();
18
+ if (result.exitCode !== 0 || result.timedOut || result.aborted ||
19
+ result.sandbox?.mode !== permissionMode || result.sandbox?.enforcement !== 'full') {
20
+ throw new Error('Flywheel sandbox readiness probe did not prove full enforcement');
21
+ }
22
+ ctx.on('session/created', session => {
23
+ // A fresh SDK session has no inherited permission facts. Pin both canonical
24
+ // knobs, rather than relying on saved UI preferences or a prompt instruction.
25
+ session.append('sandbox/mode', { mode: permissionMode });
26
+ session.append('approval/policy', { policy: 'never' });
27
+ const policy = ctx.sandboxPolicy.resolve({ session });
28
+ session.append('flywheel/policy', {
29
+ protocol: 1,
30
+ cwd: policy.workspaceRoot,
31
+ permissionMode: policy.mode,
32
+ approvalPolicy: ctx.approval.effectivePolicy(session),
33
+ confinedShell: ctx.shell.sandboxMode !== undefined,
34
+ enforcement: result.sandbox.enforcement,
35
+ });
36
+ });
37
+ }
@@ -0,0 +1,174 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { isAbsolute } from 'node:path';
3
+
4
+ export const PROTOCOL = 1;
5
+ export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
6
+ export const ERROR_CODES = new Set([
7
+ 'AUTH', 'QUOTA', 'RATE_LIMIT', 'SERVER', 'TRANSPORT', 'TIMEOUT',
8
+ 'EMPTY_RESPONSE', 'INVALID_REQUEST', 'CONTEXT_WINDOW_EXCEEDED',
9
+ ]);
10
+
11
+ export class BridgeError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ export function record(value) {
19
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
20
+ }
21
+
22
+ export function nonempty(value, label, max = 512) {
23
+ if (typeof value !== 'string' || value.length > max || value.trim() === '' || value.includes('\0')) {
24
+ throw new BridgeError('INVALID_REQUEST', `${label} must be a non-empty string (max ${max})`);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ export async function directory(value, label) {
30
+ nonempty(value, label, 4096);
31
+ if (!isAbsolute(value)) throw new BridgeError('INVALID_REQUEST', `${label} must be absolute`);
32
+ const resolved = await realpath(value);
33
+ if (!(await stat(resolved)).isDirectory()) {
34
+ throw new BridgeError('INVALID_REQUEST', `${label} must be a directory`);
35
+ }
36
+ return resolved;
37
+ }
38
+
39
+ export async function validateStart(value) {
40
+ if (!record(value) || value.type !== 'start' || value.protocol !== PROTOCOL) {
41
+ throw new BridgeError('INVALID_REQUEST', 'expected one protocol 1 start request');
42
+ }
43
+ const allowed = new Set(['type', 'protocol', 'requestId', 'cwd', 'provider', 'model',
44
+ 'reasoningEffort', 'permissionMode', 'prompt', 'maxTokens', 'timeoutMs']);
45
+ for (const key of Object.keys(value)) {
46
+ if (!allowed.has(key)) throw new BridgeError('INVALID_REQUEST', `unsupported start field: ${key}`);
47
+ }
48
+ const requestId = nonempty(value.requestId, 'requestId', 128);
49
+ const provider = nonempty(value.provider, 'provider');
50
+ const model = nonempty(value.model, 'model');
51
+ const prompt = nonempty(value.prompt, 'prompt', MAX_FRAME_BYTES);
52
+ if (!['read-only', 'workspace-write'].includes(value.permissionMode)) {
53
+ throw new BridgeError('INVALID_REQUEST', 'permissionMode must be read-only or workspace-write');
54
+ }
55
+ if (value.reasoningEffort !== undefined) nonempty(value.reasoningEffort, 'reasoningEffort', 64);
56
+ if (value.maxTokens !== undefined && (!Number.isSafeInteger(value.maxTokens) || value.maxTokens < 1)) {
57
+ throw new BridgeError('INVALID_REQUEST', 'maxTokens must be a positive integer');
58
+ }
59
+ const timeoutMs = value.timeoutMs ?? 30 * 60 * 1000;
60
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 24 * 60 * 60 * 1000) {
61
+ throw new BridgeError('INVALID_REQUEST', 'timeoutMs must be between 1 and 86400000');
62
+ }
63
+ return { ...value, requestId, provider, model, prompt, timeoutMs, cwd: await directory(value.cwd, 'cwd') };
64
+ }
65
+
66
+ export function normalizedError(error) {
67
+ return {
68
+ code: ERROR_CODES.has(error?.code) ? error.code : 'TRANSPORT',
69
+ message: error instanceof Error ? error.message : String(error),
70
+ };
71
+ }
72
+
73
+ export function modelErrorCode(code) {
74
+ if (ERROR_CODES.has(code)) return code;
75
+ if (code === 'MISSING_CREDENTIAL' || code === 'INVALID_CREDENTIAL') return 'AUTH';
76
+ if (code === 'ACCOUNT_QUOTA') return 'QUOTA';
77
+ if (code === 'NO_ADAPTER' || code === 'UNSUPPORTED_REASONING_EFFORT') return 'INVALID_REQUEST';
78
+ return 'SERVER';
79
+ }
80
+
81
+ /** Fold committed per-call records; streamed usage snapshots are never summed. */
82
+ export class SessionFold {
83
+ constructor(sessionId) {
84
+ this.sessionId = sessionId;
85
+ this.seen = new Set();
86
+ this.receipts = new Set();
87
+ this.policy = null;
88
+ this.text = '';
89
+ this.assistantCount = 0;
90
+ this.usageComplete = true;
91
+ this.usage = { input_tokens: 0, output_tokens: 0, reasoning_tokens: 0,
92
+ cache_read_tokens: 0, cache_write_tokens: 0, total_tokens: 0 };
93
+ this.terminal = null;
94
+ }
95
+
96
+ accept(params) {
97
+ if (!record(params) || params.sessionId !== this.sessionId) return false;
98
+ const event = params.event;
99
+ if (!record(event) || typeof event.type !== 'string' ||
100
+ !Number.isSafeInteger(event.seq) || event.seq < 0 || !record(event.data)) {
101
+ throw new BridgeError('TRANSPORT', 'malformed DSH session event');
102
+ }
103
+ if (this.seen.has(event.seq)) return false;
104
+ this.seen.add(event.seq);
105
+ const data = event.data;
106
+ if (event.type === 'flywheel/policy') this.policy = data;
107
+ if (event.type === 'agent/inbox/spliced' && Array.isArray(data.inserted)) {
108
+ for (const message of data.inserted) if (typeof message?.id === 'string') this.receipts.add(message.id);
109
+ }
110
+ if (event.type === 'assistant/message') {
111
+ if (!Array.isArray(data.message?.content) || !data.message.content.every(block =>
112
+ record(block) && typeof block.type === 'string' &&
113
+ (block.type !== 'text' || typeof block.text === 'string'))) {
114
+ throw new BridgeError('TRANSPORT', 'malformed committed assistant message');
115
+ }
116
+ this.assistantCount += 1;
117
+ this.text = data.message.content.filter(block => block.type === 'text').map(block => block.text).join('');
118
+ this.addUsage(data.usage);
119
+ }
120
+ if (event.type === 'turn/end') {
121
+ if (!record(data.reason) || typeof data.reason.kind !== 'string') {
122
+ throw new BridgeError('TRANSPORT', 'malformed DSH turn terminal');
123
+ }
124
+ this.terminal = data.reason;
125
+ }
126
+ return true;
127
+ }
128
+
129
+ addUsage(usage) {
130
+ const valid = value => Number.isSafeInteger(value) && value >= 0;
131
+ const fields = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'totalTokens'];
132
+ if (!record(usage) || !valid(usage.inputTokens) || !valid(usage.outputTokens) ||
133
+ fields.some(key => usage[key] !== undefined && !valid(usage[key]))) {
134
+ this.usageComplete = false;
135
+ return;
136
+ }
137
+ const cacheRead = usage.cacheReadTokens ?? 0;
138
+ const cacheWrite = usage.cacheWriteTokens ?? 0;
139
+ const input = usage.inputTokens + cacheRead + cacheWrite;
140
+ const values = {
141
+ input_tokens: input, output_tokens: usage.outputTokens,
142
+ reasoning_tokens: usage.reasoningTokens ?? 0,
143
+ cache_read_tokens: cacheRead, cache_write_tokens: cacheWrite,
144
+ total_tokens: usage.totalTokens ?? input + usage.outputTokens,
145
+ };
146
+ for (const [key, value] of Object.entries(values)) {
147
+ if (!Number.isSafeInteger(this.usage[key] + value)) this.usageComplete = false;
148
+ else this.usage[key] += value;
149
+ }
150
+ }
151
+
152
+ verifiedPolicy(request) {
153
+ const policy = this.policy;
154
+ if (!policy || policy.protocol !== PROTOCOL || policy.permissionMode !== request.permissionMode ||
155
+ policy.cwd !== request.cwd || policy.approvalPolicy !== 'never' ||
156
+ policy.confinedShell !== true || policy.enforcement !== 'full') {
157
+ throw new BridgeError('INVALID_REQUEST', 'DSH did not attest the requested cwd and confined permission policy; prompt withheld');
158
+ }
159
+ }
160
+
161
+ finish(messageId) {
162
+ if (!this.receipts.has(messageId)) throw new BridgeError('TRANSPORT', 'missing durable prompt receipt');
163
+ if (!this.terminal) throw new BridgeError('TRANSPORT', 'idle without committed turn/end');
164
+ if (this.terminal.kind !== 'completed') {
165
+ const detail = this.terminal.error;
166
+ throw new BridgeError(modelErrorCode(detail?.code),
167
+ detail?.message ?? `DSH turn ended as ${this.terminal.kind}`);
168
+ }
169
+ if (!this.assistantCount || this.text.trim() === '') {
170
+ throw new BridgeError('EMPTY_RESPONSE', 'DSH completed without assistant text');
171
+ }
172
+ return { text: this.text, usage: this.usageComplete ? this.usage : null };
173
+ }
174
+ }
package/dist/worker.js ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { runWorker } from './bridge.js';
3
+ import { BridgeError, MAX_FRAME_BYTES, normalizedError } from './protocol.js';
4
+
5
+ const controller = new AbortController();
6
+ process.once('SIGTERM', () => controller.abort());
7
+ process.once('SIGINT', () => controller.abort());
8
+ // The engine writes exactly one request then closes stdin. EOF after admission
9
+ // is normal and must not cancel an in-flight model turn.
10
+ async function readInput() {
11
+ const chunks = [];
12
+ let bytes = 0;
13
+ const admission = setTimeout(() => process.stdin.destroy(new BridgeError('TIMEOUT', 'no complete worker request within 10 seconds')), 10000);
14
+ try {
15
+ for await (const chunk of process.stdin) {
16
+ bytes += chunk.length;
17
+ if (bytes > MAX_FRAME_BYTES) throw new BridgeError('INVALID_REQUEST', 'worker request exceeds 8 MiB');
18
+ chunks.push(chunk);
19
+ }
20
+ const lines = Buffer.concat(chunks).toString('utf8').trim().split('\n');
21
+ if (lines.length !== 1 || !lines[0]) throw new BridgeError('INVALID_REQUEST', 'expected exactly one JSON line then EOF');
22
+ try { return JSON.parse(lines[0]); } catch {
23
+ throw new BridgeError('INVALID_REQUEST', 'worker request is not valid JSON');
24
+ }
25
+ } finally {
26
+ clearTimeout(admission);
27
+ }
28
+ }
29
+
30
+ const emit = value => process.stdout.write(JSON.stringify(value) + '\n');
31
+ try {
32
+ if (process.argv.slice(2).some(arg => arg !== '--doctor') || process.argv.length > 3) {
33
+ throw new BridgeError('INVALID_REQUEST', 'worker accepts only optional --doctor');
34
+ }
35
+ const request = await readInput();
36
+ const result = await runWorker(request, {
37
+ emit, signal: controller.signal, doctor: process.argv.includes('--doctor'),
38
+ stderr: chunk => process.stderr.write(chunk),
39
+ });
40
+ process.exitCode = result.status === 'ok' ? 0 : result.status === 'cancelled' ? 143 : 1;
41
+ } catch (error) {
42
+ emit({ protocol: 1, requestId: 'invalid-request', type: 'result', status: 'error', settled: true,
43
+ text: '', usage: null, costUsd: null, error: normalizedError(error) });
44
+ process.exitCode = 1;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,78 @@
1
1
  {
2
2
  "name": "@ckanner/dsh-plugin-flywheel",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.28.0",
4
+ "description": "Flywheel engine controls with owned DSH SDK and Codex Pro machine workers",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "engines": {
8
+ "node": ">=22.18.0",
9
+ "dsh": "0.2.1-rc.5"
10
+ },
11
+ "exports": {
12
+ ".": "./dist/index.js",
13
+ "./client": "./dist/client.js",
14
+ "./worker-policy": "./dist/policy.js",
15
+ "./worker": "./dist/worker.js",
16
+ "./codex-pro-worker": "./dist/codex-pro-worker.js",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "bin": {
20
+ "flywheel-dsh-worker": "./dist/worker.js",
21
+ "flywheel-codex-pro-worker": "./dist/codex-pro-worker.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "cordis.patch.yml",
26
+ "README.md",
27
+ "README.en.md",
28
+ "PROTOCOL.md"
29
+ ],
30
+ "dsh": {
31
+ "bundle": {
32
+ "patch": "./cordis.patch.yml"
33
+ },
34
+ "client": {
35
+ "inject": [
36
+ "@deepseek-ai/dsh-client-ui-chat",
37
+ "@deepseek-ai/dsh-client-ui-commands",
38
+ "@deepseek-ai/dsh-api-remotes"
39
+ ],
40
+ "platform": "web"
41
+ }
42
+ },
43
+ "peerDependencies": {
44
+ "@deepseek-ai/cordis": "~4.0.5",
45
+ "@deepseek-ai/dsh-commands": "0.2.1-rc.5",
46
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.5"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@deepseek-ai/cordis": {
50
+ "optional": true
51
+ },
52
+ "@deepseek-ai/dsh-commands": {
53
+ "optional": true
54
+ },
55
+ "@deepseek-ai/dsh-tools": {
56
+ "optional": true
57
+ }
58
+ },
59
+ "scripts": {
60
+ "build": "node scripts/build.mjs",
61
+ "test": "npm run build && node --test test/*.test.js",
62
+ "check": "npm test",
63
+ "prepack": "npm run build"
64
+ },
65
+ "dependencies": {
66
+ "dsh-subagent-codex-pro": "0.8.0"
67
+ },
68
+ "devDependencies": {
69
+ "jsdom": "26.1.0",
70
+ "react": "18.3.1",
71
+ "react-dom": "18.3.1"
72
+ },
73
+ "repository": {
74
+ "type": "git",
75
+ "url": "git+https://github.com/ckanner/flywheel.git",
76
+ "directory": "plugins/dsh"
77
+ }
78
+ }