@myagentroam/marctl 0.11.2 → 0.11.4

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.
@@ -121,9 +121,12 @@ Reading JSON:
121
121
  Delivery and waiting:
122
122
  Start/send return a clientMessageId.
123
123
  Send requires exactly one of --steer or --queue, even when idle.
124
- --steer prioritizes time-sensitive updates affecting current work and may
125
- switch the current execution. Node decides from actual state.
126
- --queue does not interrupt active execution; during normal active work it
124
+ Prefer --steer for timely coordination: blockers, decisions, corrections,
125
+ and results the recipient is waiting for. It uses native delivery when
126
+ available, but may switch the current execution.
127
+ Node decides from actual state.
128
+ Use --queue only for deliberately deferred follow-up work.
129
+ It does not interrupt active execution; during normal active work it
127
130
  waits for that execution and earlier queued work. An active Goal can delay
128
131
  it until the Goal exits.
129
132
  Use --steer instead when the update is needed during current work.
@@ -118,9 +118,11 @@ ${configurationHelp}
118
118
  投递与等待:
119
119
  start/send 返回 clientMessageId。
120
120
  send 必须二选一:--steer 或 --queue,空闲时也需指定。
121
- --steer 优先补充影响当前工作的及时信息,可能切换当前执行;
122
- Node 按实际状态裁决。
123
- --queue 不打断活动执行;正常运行时等待当前执行和前面的队列项;
121
+ 及时协作优先使用 --steer:阻塞、决策请求、纠偏
122
+ 和对方正在等待的结果。可用时优先原生投递,
123
+ 否则可能切换当前执行;Node 按实际状态裁决。
124
+ 仅明确要延后执行的后续工作使用 --queue。
125
+ 它不打断活动执行;正常运行时等待当前执行和前面的队列项;
124
126
  有活动 Goal 时可能等待 Goal 退出才处理。
125
127
  当前工作需要及时获知的信息应使用 --steer。
126
128
  新输入自动恢复暂停的 FIFO,不打断活动执行;取消收敛后才恢复。
@@ -0,0 +1,99 @@
1
+ import { MarctlError } from './errors.mjs';
2
+ import { CHUNK, WINDOW } from './scp-protocol.mjs';
3
+
4
+ const MAX_BUFFERED_BYTES = WINDOW * (CHUNK + 4) + 64;
5
+
6
+ export class ByteChannel {
7
+ #buffer = Buffer.alloc(0);
8
+ #waiting = [];
9
+ #sending = new Map();
10
+ #ended;
11
+ #failure;
12
+ #controller;
13
+
14
+ constructor(send, controller) {
15
+ this.#controller = controller;
16
+ const signal = controller.signal;
17
+ this.send = (bytes) => {
18
+ if (this.#failure) throw this.#failure;
19
+ if (this.#ended !== undefined) throw new MarctlError('Copy stream ended unexpectedly.');
20
+ return new Promise((resolve, reject) => {
21
+ this.#sending.set(reject, resolve);
22
+ const settle = (complete, value) => {
23
+ this.#sending.delete(reject);
24
+ complete(value);
25
+ };
26
+ try {
27
+ Promise.resolve(send(bytes)).then(
28
+ (value) => settle(resolve, value),
29
+ (error) => settle(reject, error)
30
+ );
31
+ } catch (error) {
32
+ settle(reject, error);
33
+ }
34
+ });
35
+ };
36
+ if (signal) {
37
+ if (signal.aborted) this.fail(signal.reason);
38
+ else
39
+ signal.addEventListener('abort', () => this.fail(signal.reason), {
40
+ once: true
41
+ });
42
+ }
43
+ }
44
+
45
+ feed(bytes) {
46
+ if (this.#failure) return;
47
+ this.#buffer = Buffer.concat([this.#buffer, bytes]);
48
+ if (this.#buffer.length > MAX_BUFFERED_BYTES)
49
+ return this.fail(new MarctlError('Copy output exceeded the bounded window.'));
50
+ this.#wake();
51
+ }
52
+
53
+ finish(code) {
54
+ this.#ended = code;
55
+ if (code !== 0) return this.fail(new MarctlError('Remote copy command failed.'));
56
+ // A successful exit confirms the worker consumed its final protocol input.
57
+ for (const resolve of this.#sending.values()) resolve();
58
+ this.#sending.clear();
59
+ this.#wake();
60
+ }
61
+
62
+ connectionEnded(error) {
63
+ if (this.#ended === undefined) this.fail(error);
64
+ }
65
+
66
+ fail(error) {
67
+ if (this.#failure !== undefined) return;
68
+ this.#failure = error;
69
+ for (const reject of this.#sending.keys()) reject(this.#failure);
70
+ this.#sending.clear();
71
+ this.#wake();
72
+ // Any endpoint failure must wake reads and writes across the entire copy.
73
+ this.#controller.abort(error);
74
+ }
75
+
76
+ #wake() {
77
+ for (const resolve of this.#waiting.splice(0)) resolve();
78
+ }
79
+
80
+ async read(length) {
81
+ if (this.#failure) throw this.#failure;
82
+ while (this.#buffer.length < length) {
83
+ if (this.#failure) throw this.#failure;
84
+ if (this.#ended !== undefined) throw new MarctlError('Copy stream ended unexpectedly.');
85
+ await new Promise((resolve) => this.#waiting.push(resolve));
86
+ }
87
+ const bytes = this.#buffer.subarray(0, length);
88
+ this.#buffer = this.#buffer.subarray(length);
89
+ return bytes;
90
+ }
91
+
92
+ async done() {
93
+ if (this.#failure) throw this.#failure;
94
+ while (this.#ended === undefined && this.#failure === undefined)
95
+ await new Promise((resolve) => this.#waiting.push(resolve));
96
+ if (this.#failure) throw this.#failure;
97
+ if (this.#ended !== 0) throw new MarctlError('Remote copy command failed.');
98
+ }
99
+ }
@@ -0,0 +1,46 @@
1
+ import { MarctlError } from './errors.mjs';
2
+ import { CHUNK, WINDOW } from './scp-protocol.mjs';
3
+
4
+ export async function copyBytes(source, destination) {
5
+ const sizeBytes = await source.read(8);
6
+ const size = sizeBytes.readBigUInt64BE();
7
+ if (size > BigInt(Number.MAX_SAFE_INTEGER)) throw new MarctlError('Copy source is too large.');
8
+ const metadata = await source.read(20);
9
+ await destination.open(Number(size), metadata, async (target) => {
10
+ if ((await target.read(1))[0] !== 1)
11
+ throw new MarctlError('Copy target did not accept the file.');
12
+ let remaining = Number(size);
13
+ let acknowledged = Promise.resolve();
14
+ const pending = [];
15
+ for (;;) {
16
+ const header = await source.read(4);
17
+ const length = header.readUInt32BE();
18
+ if (length > CHUNK || length > remaining)
19
+ throw new MarctlError('Copy source sent an invalid chunk.');
20
+ await target.send(header);
21
+ if (length === 0) {
22
+ await acknowledged;
23
+ await target.send(await source.read(32));
24
+ if (remaining !== 0 || (await target.read(1))[0] !== 2)
25
+ throw new MarctlError('Copy target did not verify the file.');
26
+ break;
27
+ }
28
+ await target.send(await source.read(length));
29
+ // Keep one ordered ACK reader while sending up to a bounded window ahead.
30
+ acknowledged = acknowledged.then(async () => {
31
+ if ((await target.read(1))[0] !== 1)
32
+ throw new MarctlError('Copy target rejected the chunk.');
33
+ await source.send(Buffer.from([1]));
34
+ });
35
+ acknowledged.catch((error) => {
36
+ // ACK failures use the same copy-wide cancellation as transport failures.
37
+ source.fail(error);
38
+ });
39
+ pending.push(acknowledged);
40
+ remaining -= length;
41
+ if (pending.length >= WINDOW) await pending.shift();
42
+ }
43
+ await target.done();
44
+ });
45
+ await source.done();
46
+ }
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+ import { MarctlError } from './errors.mjs';
3
+
4
+ export function parseScpEndpoint(value) {
5
+ if (typeof value !== 'string' || !value || value.includes('\0'))
6
+ throw new MarctlError('Invalid copy endpoint.');
7
+ const explicit = value.indexOf('://');
8
+ const separator = explicit >= 0 ? explicit : value.indexOf(':');
9
+ if (separator < 0 || (separator === 1 && /^[A-Za-z]:[\\/]/u.test(value))) {
10
+ // Let the filesystem resolve dot components and symlink parents.
11
+ return { path: path.isAbsolute(value) ? value : `${process.cwd()}${path.sep}${value}` };
12
+ }
13
+ const host = value.slice(0, separator);
14
+ const file = value.slice(separator + (explicit >= 0 ? 3 : 1));
15
+ if (!host || !file || (!file.startsWith('/') && !/^[A-Za-z]:[\\/]/u.test(file)))
16
+ throw new MarctlError('Invalid Node file path.');
17
+ return { host, path: file };
18
+ }
19
+
20
+ export function scpSourceBasename(value) {
21
+ const windowsPath = /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\');
22
+ if (value.endsWith('/') || (windowsPath && value.endsWith('\\')))
23
+ throw new MarctlError('Invalid source filename.');
24
+ const sourcePath = windowsPath ? path.win32 : path.posix;
25
+ const basename = sourcePath.basename(value);
26
+ if (!basename || basename === '.' || basename === '..')
27
+ throw new MarctlError('Invalid source filename.');
28
+ return basename;
29
+ }
@@ -0,0 +1,2 @@
1
+ export const CHUNK = 32 * 1024;
2
+ export const WINDOW = 8;
@@ -0,0 +1,179 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { gzipSync } from 'node:zlib';
4
+ import { MarctlError } from './errors.mjs';
5
+ import { DirectRejectedError } from './direct-errors.mjs';
6
+ import { runTerminalSession } from './terminal.mjs';
7
+ import { parseFrame } from './terminal-frame.mjs';
8
+ import { ByteChannel } from './scp-channel.mjs';
9
+
10
+ const script = gzipSync(readFileSync(new URL('./scp-worker.cjs', import.meta.url))).toString(
11
+ 'base64'
12
+ );
13
+ const expression = `eval(require('zlib').gunzipSync(Buffer.from('${script}','base64')).toString())`;
14
+
15
+ function workerArguments(mode, file, size, metadata, preserve, sourceBasename) {
16
+ const encoded = Buffer.from(file, 'utf8').toString('base64url');
17
+ return [
18
+ mode,
19
+ encoded,
20
+ ...(size === undefined
21
+ ? []
22
+ : [
23
+ String(size),
24
+ metadata.toString('base64url'),
25
+ preserve ? '1' : '0',
26
+ Buffer.from(sourceBasename, 'utf8').toString('base64url')
27
+ ])
28
+ ];
29
+ }
30
+
31
+ function workerCommand(mode, file, size, metadata, preserve, sourceBasename) {
32
+ return `node -e "${expression}" ${workerArguments(mode, file, size, metadata, preserve, sourceBasename).join(' ')}`;
33
+ }
34
+
35
+ async function withLocalWorker(
36
+ mode,
37
+ file,
38
+ size,
39
+ metadata,
40
+ preserve,
41
+ sourceBasename,
42
+ use,
43
+ controller
44
+ ) {
45
+ const child = spawn(
46
+ process.execPath,
47
+ ['-e', expression, ...workerArguments(mode, file, size, metadata, preserve, sourceBasename)],
48
+ {
49
+ stdio: ['pipe', 'pipe', 'pipe'],
50
+ windowsHide: true
51
+ }
52
+ );
53
+ child.stdin.on('error', () => undefined);
54
+ child.stderr.resume();
55
+ const channel = new ByteChannel(
56
+ (bytes) =>
57
+ new Promise((resolve, reject) => {
58
+ child.stdin.write(bytes, (error) => (error ? reject(error) : resolve()));
59
+ }),
60
+ controller
61
+ );
62
+ child.stdout.on('data', (bytes) => channel.feed(bytes));
63
+ child.once('error', (error) => channel.fail(error));
64
+ const closed = new Promise((resolve) => {
65
+ child.once('close', (code) => {
66
+ channel.finish(code ?? 1);
67
+ resolve();
68
+ });
69
+ });
70
+ try {
71
+ return await use(channel);
72
+ } finally {
73
+ if (child.exitCode === null) child.kill();
74
+ await closed;
75
+ }
76
+ }
77
+
78
+ async function withRemoteWorker(
79
+ {
80
+ client,
81
+ WebSocketClass,
82
+ nodeId,
83
+ file,
84
+ mode,
85
+ size,
86
+ metadata,
87
+ preserve,
88
+ sourceBasename,
89
+ translate,
90
+ controller
91
+ },
92
+ use
93
+ ) {
94
+ return runTerminalSession({
95
+ client,
96
+ WebSocketClass,
97
+ nodeId,
98
+ command: workerCommand(mode, file, size, metadata, preserve, sourceBasename),
99
+ binary: true,
100
+ raceConnections: true,
101
+ signal: controller.signal,
102
+ translate,
103
+ attached: async ({ transport, terminalId }) => {
104
+ const channel = new ByteChannel(
105
+ (bytes) =>
106
+ transport.send(
107
+ JSON.stringify({ type: 'input', terminalId, data: bytes.toString('base64') })
108
+ ),
109
+ controller
110
+ );
111
+ let sequence = 0;
112
+ const onMessage = (raw, isBinary) => {
113
+ try {
114
+ const frame = parseFrame(raw, isBinary, terminalId, translate);
115
+ if (frame.type === 'output') {
116
+ if (frame.sequence !== sequence + 1)
117
+ throw new MarctlError('Copy output sequence is invalid.');
118
+ sequence = frame.sequence;
119
+ const bytes = Buffer.from(frame.data, 'base64');
120
+ if (bytes.toString('base64') !== frame.data)
121
+ throw new MarctlError('Copy output is not valid base64.');
122
+ channel.feed(bytes);
123
+ } else if (frame.type === 'exit') channel.finish(frame.exitCode ?? 1);
124
+ else if (frame.type === 'error')
125
+ channel.fail(new MarctlError(translate('errors.terminalRemoteError')));
126
+ else throw new MarctlError(translate('errors.terminalUnexpectedFrame'));
127
+ } catch (error) {
128
+ channel.fail(error);
129
+ }
130
+ };
131
+ const onError = (error) =>
132
+ channel.connectionEnded(
133
+ error instanceof DirectRejectedError
134
+ ? new MarctlError(`${translate('errors.terminalConnectionFailed')} [${error.code}]`, {
135
+ code: error.code
136
+ })
137
+ : new MarctlError(translate('errors.terminalConnectionFailed'))
138
+ );
139
+ const onClose = () =>
140
+ channel.connectionEnded(new MarctlError(translate('errors.terminalConnectionLost')));
141
+ transport.on('message', onMessage);
142
+ transport.on('error', onError);
143
+ transport.on('close', onClose);
144
+ try {
145
+ transport.activate?.();
146
+ return await use(channel);
147
+ } finally {
148
+ transport.removeListener?.('message', onMessage);
149
+ transport.removeListener?.('error', onError);
150
+ transport.removeListener?.('close', onClose);
151
+ }
152
+ }
153
+ });
154
+ }
155
+
156
+ export async function withWorker(endpoint, mode, size, metadata, context, use) {
157
+ if (endpoint.nodeId === undefined)
158
+ return withLocalWorker(
159
+ mode,
160
+ endpoint.path,
161
+ size,
162
+ metadata,
163
+ context.preserve,
164
+ context.sourceBasename,
165
+ use,
166
+ context.controller
167
+ );
168
+ return withRemoteWorker(
169
+ {
170
+ ...context,
171
+ nodeId: endpoint.nodeId,
172
+ file: endpoint.path,
173
+ mode,
174
+ size,
175
+ metadata
176
+ },
177
+ use
178
+ );
179
+ }