@byokit/openclaw 0.3.4 → 0.3.5
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/CHANGELOG.md +6 -0
- package/README.md +8 -1
- package/dist/auth-store.js +28 -20
- package/dist/engine-status.d.ts +6 -0
- package/dist/engine-status.js +26 -0
- package/dist/engine.d.ts +3 -1
- package/dist/engine.js +68 -13
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/kit.js +4 -0
- package/dist/types.d.ts +1 -1
- package/dist/words.js +2 -0
- package/dist/words.json +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.5 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
- FIX: Failed engine starts no longer remove another live gateway’s guards or saved sign-in state. Verified dead-host gateways stop gracefully before restart; ambiguous ownership returns a typed engine-already-running error and preserves the store.
|
|
10
|
+
|
|
5
11
|
## 0.3.4 (2026-09-30)
|
|
6
12
|
|
|
7
13
|
|
package/README.md
CHANGED
|
@@ -232,7 +232,14 @@ before any restoration; a wrong key, tampering, or a missing adapter rejects. Fi
|
|
|
232
232
|
have mode 0600 and directories 0700. File symlinks are sealed only when their fully resolved targets are
|
|
233
233
|
regular files inside the isolated engine root; they restore as regular files at the link paths. Outside-root,
|
|
234
234
|
dangling and directory symlinks (including loops), sockets, FIFOs and devices are skipped.
|
|
235
|
-
One kit owns the store at a time
|
|
235
|
+
One kit owns the store at a time. After a host crash, preparation stops a verified same-user gateway
|
|
236
|
+
only when its host lock owner is dead, waits up to three seconds for exit, then seals and restores the
|
|
237
|
+
leftover state under the acquired lock. If process identity is ambiguous (including unavailable process
|
|
238
|
+
inspection or a rewritten command line), another host is alive, or shutdown times out, `prepare()` and
|
|
239
|
+
`start()` reject with exported `EngineAlreadyRunningError` (`code: 'engine-already-running'`). The kit reports
|
|
240
|
+
`{ phase: 'failed', why: 'engine-already-running' }`; show `stateWords(kit.state)` and retry after the other
|
|
241
|
+
session stops. Failed-start cleanup and `stop()` on an instance with no ownership preserve the other
|
|
242
|
+
writer's pid, lock, sealed snapshot and live state.
|
|
236
243
|
Sealing copies the whole store through memory, so startup and stop cost grows with session history.
|
|
237
244
|
|
|
238
245
|
The migration doctor temporarily opens the same store and reseals it even when import fails. Once the gateway
|
package/dist/auth-store.js
CHANGED
|
@@ -2,20 +2,10 @@
|
|
|
2
2
|
// Seal the complete isolated stores, including journals, only after their writer has exited.
|
|
3
3
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, closeSync, fsyncSync, openSync } from 'node:fs';
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { EngineAlreadyRunningError, pidAlive as live } from "./engine-status.js";
|
|
5
6
|
const encoder = new TextEncoder();
|
|
6
7
|
const archive = (name) => /^(auth-profiles|auth-state|auth|oauth)\.json\.(migrated-.+|sqlite-import\..+\.bak)$/.test(name)
|
|
7
8
|
|| name.endsWith('.moved-to-engine');
|
|
8
|
-
const live = (pid) => {
|
|
9
|
-
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
10
|
-
return false;
|
|
11
|
-
try {
|
|
12
|
-
process.kill(pid, 0);
|
|
13
|
-
return true;
|
|
14
|
-
}
|
|
15
|
-
catch (error) {
|
|
16
|
-
return error.code !== 'ESRCH';
|
|
17
|
-
}
|
|
18
|
-
};
|
|
19
9
|
function regular(path) {
|
|
20
10
|
if (!lstatSync(path).isFile())
|
|
21
11
|
throw new Error(`credential store requires regular files: ${path}`);
|
|
@@ -108,18 +98,16 @@ export class AuthStore {
|
|
|
108
98
|
return;
|
|
109
99
|
if (existsSync(this.file) && !this.o.seal)
|
|
110
100
|
throw new Error('authSeal required for sealed credential store');
|
|
111
|
-
if (!this.o.seal)
|
|
112
|
-
return;
|
|
113
101
|
// Never race a live gateway or another kit, including an orphan after its host crashed.
|
|
114
102
|
const gateway = join(this.o.root, 'gateway.pid');
|
|
115
103
|
if (existsSync(gateway) && live(Number(readFileSync(gateway, 'utf8'))))
|
|
116
|
-
throw new
|
|
104
|
+
throw new EngineAlreadyRunningError();
|
|
117
105
|
if (existsSync(this.lock)) {
|
|
118
106
|
if (!lstatSync(this.lock).isDirectory() || lstatSync(this.lock).isSymbolicLink())
|
|
119
107
|
throw new Error('invalid credential lock');
|
|
120
108
|
const owner = join(this.lock, 'pid');
|
|
121
109
|
if (!existsSync(owner) || live(Number(readFileSync(owner, 'utf8'))))
|
|
122
|
-
throw new
|
|
110
|
+
throw new EngineAlreadyRunningError();
|
|
123
111
|
// Only one stale-owner recovery can proceed. Recheck after claiming it so a stale reader
|
|
124
112
|
// cannot remove the lock a newer owner has just acquired.
|
|
125
113
|
const recovery = join(this.lock, 'recovery');
|
|
@@ -127,17 +115,36 @@ export class AuthStore {
|
|
|
127
115
|
mkdirSync(recovery, { mode: 0o700 });
|
|
128
116
|
}
|
|
129
117
|
catch {
|
|
130
|
-
throw new
|
|
118
|
+
throw new EngineAlreadyRunningError();
|
|
131
119
|
}
|
|
132
120
|
if (!existsSync(owner) || live(Number(readFileSync(owner, 'utf8')))) {
|
|
133
121
|
rmSync(recovery, { recursive: true });
|
|
134
|
-
throw new
|
|
122
|
+
throw new EngineAlreadyRunningError();
|
|
135
123
|
}
|
|
136
124
|
rmSync(this.lock, { recursive: true });
|
|
137
125
|
}
|
|
138
|
-
|
|
139
|
-
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(this.lock, { mode: 0o700 });
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error.code === 'EEXIST')
|
|
131
|
+
throw new EngineAlreadyRunningError();
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
140
134
|
this.owned = true;
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(join(this.lock, 'pid'), String(process.pid), { mode: 0o600, flag: 'wx' });
|
|
137
|
+
// Recheck under the acquired lock before removing a dead writer's guard.
|
|
138
|
+
if (existsSync(gateway)) {
|
|
139
|
+
if (live(Number(readFileSync(gateway, 'utf8'))))
|
|
140
|
+
throw new EngineAlreadyRunningError();
|
|
141
|
+
removeMarker(gateway);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
this.release();
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
141
148
|
}
|
|
142
149
|
release() {
|
|
143
150
|
if (!this.owned)
|
|
@@ -297,7 +304,8 @@ export class AuthStore {
|
|
|
297
304
|
}
|
|
298
305
|
stop() {
|
|
299
306
|
return this.serial(async () => {
|
|
300
|
-
this.
|
|
307
|
+
if (!this.owned)
|
|
308
|
+
return;
|
|
301
309
|
await this.archives();
|
|
302
310
|
await this.persist();
|
|
303
311
|
this.active = false;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** A live or unverifiable writer still owns the isolated engine state. Retry after it stops. */
|
|
2
|
+
export declare class EngineAlreadyRunningError extends Error {
|
|
3
|
+
readonly code = "engine-already-running";
|
|
4
|
+
constructor();
|
|
5
|
+
}
|
|
6
|
+
export declare function pidAlive(pid: number): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
/** A live or unverifiable writer still owns the isolated engine state. Retry after it stops. */
|
|
3
|
+
export class EngineAlreadyRunningError extends Error {
|
|
4
|
+
code = 'engine-already-running';
|
|
5
|
+
constructor() {
|
|
6
|
+
super('engine already running; credential store is in use');
|
|
7
|
+
this.name = 'EngineAlreadyRunningError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function pidAlive(pid) {
|
|
11
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
12
|
+
return true; // An invalid guard is ambiguous, never stale.
|
|
13
|
+
try {
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
// An orphan can remain a zombie until its new parent reaps it; it has no writer left.
|
|
16
|
+
if (process.platform === 'linux') {
|
|
17
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
18
|
+
if (stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z '))
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return error.code !== 'ESRCH';
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/engine.d.ts
CHANGED
|
@@ -36,7 +36,8 @@ export declare class Engine {
|
|
|
36
36
|
doctor(timeoutMs: number): {
|
|
37
37
|
status: number | null;
|
|
38
38
|
};
|
|
39
|
-
private
|
|
39
|
+
private verifiedOrphan;
|
|
40
|
+
private recoverOrphan;
|
|
40
41
|
start(): Promise<{
|
|
41
42
|
port: number;
|
|
42
43
|
token: string;
|
|
@@ -45,6 +46,7 @@ export declare class Engine {
|
|
|
45
46
|
private locked;
|
|
46
47
|
private startOnce;
|
|
47
48
|
private launch;
|
|
49
|
+
private removeOwnedPid;
|
|
48
50
|
withAuthStore<T>(task: () => Promise<T>): Promise<T>;
|
|
49
51
|
stop(): Promise<void>;
|
|
50
52
|
}
|
package/dist/engine.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { randomBytes, generateKeyPairSync } from 'node:crypto';
|
|
3
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, copyFileSync, rmSync, realpathSync, statSync } from 'node:fs';
|
|
4
4
|
import { createServer } from 'node:net';
|
|
5
5
|
import { join, dirname } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
8
|
import { AuthStore } from "./auth-store.js";
|
|
9
|
+
import { EngineAlreadyRunningError, pidAlive } from "./engine-status.js";
|
|
9
10
|
import { ENGINE_VERSION } from "./constants.js";
|
|
10
11
|
import { reconcileConfig } from "./config.js";
|
|
11
12
|
import { writePlugin, resolveBridge } from "./bridge.js";
|
|
@@ -93,6 +94,7 @@ export class Engine {
|
|
|
93
94
|
async prepareOnce() {
|
|
94
95
|
if (!this.o.authSeal && existsSync(join(this.root, 'auth-store.sealed')))
|
|
95
96
|
throw new Error('authSeal required for sealed credential store');
|
|
97
|
+
await this.recoverOrphan();
|
|
96
98
|
for (const d of [this.root, ...(!this.o.authSeal ? [join(this.root, 'home'), join(this.root, 'state')] : []), join(this.root, 'tmp'), join(this.root, 'install-home'), join(this.root, 'npm-cache'), join(this.o.stateDir, 'logs'), this.dir])
|
|
97
99
|
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
98
100
|
try {
|
|
@@ -168,19 +170,67 @@ export class Engine {
|
|
|
168
170
|
const result = spawnSync(process.execPath, [entry, 'doctor', '--fix', '--yes', '--non-interactive'], { cwd: env.HOME, env, timeout: timeoutMs, stdio: 'pipe' });
|
|
169
171
|
return { status: result.status };
|
|
170
172
|
}
|
|
171
|
-
|
|
173
|
+
verifiedOrphan(pid) {
|
|
174
|
+
if (process.platform !== 'linux' || !process.getuid)
|
|
175
|
+
return false;
|
|
176
|
+
try {
|
|
177
|
+
const proc = `/proc/${pid}`;
|
|
178
|
+
const cmd = readFileSync(`${proc}/cmdline`, 'utf8').split('\0');
|
|
179
|
+
const env = readFileSync(`${proc}/environ`, 'utf8').split('\0');
|
|
180
|
+
const home = join(this.root, 'home');
|
|
181
|
+
const owner = join(this.root, 'auth-store.lock', 'pid');
|
|
182
|
+
return statSync(proc).uid === process.getuid()
|
|
183
|
+
&& realpathSync(`${proc}/exe`) === realpathSync(process.execPath)
|
|
184
|
+
&& cmd.indexOf(this.entry) > 0 && cmd[cmd.indexOf(this.entry) + 1] === 'gateway'
|
|
185
|
+
&& realpathSync(`${proc}/cwd`) === realpathSync(home)
|
|
186
|
+
&& env.includes(`HOME=${home}`)
|
|
187
|
+
&& env.includes(`OPENCLAW_STATE_DIR=${join(this.root, 'state')}`)
|
|
188
|
+
&& env.includes(`OPENCLAW_CONFIG_PATH=${join(this.root, 'openclaw.json')}`)
|
|
189
|
+
&& existsSync(owner) && !pidAlive(Number(readFileSync(owner, 'utf8')));
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async recoverOrphan() {
|
|
196
|
+
if (this.child && this.child.exitCode === null && this.child.signalCode === null)
|
|
197
|
+
return;
|
|
172
198
|
const path = join(this.root, 'gateway.pid');
|
|
173
199
|
if (!existsSync(path))
|
|
174
200
|
return;
|
|
175
201
|
const pid = Number(readFileSync(path, 'utf8'));
|
|
176
|
-
if (!
|
|
177
|
-
return;
|
|
202
|
+
if (!pidAlive(pid))
|
|
203
|
+
return; // AuthStore removes stale guards only under its lock.
|
|
204
|
+
const occupied = () => { this.state('failed', 'engine-already-running'); return new EngineAlreadyRunningError(); };
|
|
205
|
+
if (!this.verifiedOrphan(pid))
|
|
206
|
+
throw occupied();
|
|
207
|
+
// Verify a stable process identity again immediately before signalling this pid, never its group.
|
|
208
|
+
const identity = () => {
|
|
209
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
210
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]; // starttime, not a reusable pid alone
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
const before = identity();
|
|
214
|
+
if (!before || !this.verifiedOrphan(pid) || before !== identity())
|
|
215
|
+
throw occupied();
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
if (!pidAlive(pid))
|
|
219
|
+
return;
|
|
220
|
+
throw occupied();
|
|
221
|
+
}
|
|
178
222
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
223
|
+
process.kill(pid, 'SIGTERM');
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error.code !== 'ESRCH')
|
|
227
|
+
throw occupied();
|
|
182
228
|
}
|
|
183
|
-
|
|
229
|
+
for (let i = 0; i < 15 && pidAlive(pid); i++)
|
|
230
|
+
await delay(200);
|
|
231
|
+
if (pidAlive(pid))
|
|
232
|
+
throw occupied();
|
|
233
|
+
// The new store owner now seals crash leftovers under its lock before restoring them.
|
|
184
234
|
}
|
|
185
235
|
start() {
|
|
186
236
|
if (this.starting)
|
|
@@ -215,7 +265,9 @@ export class Engine {
|
|
|
215
265
|
return this.launch();
|
|
216
266
|
}
|
|
217
267
|
catch (error) {
|
|
218
|
-
await
|
|
268
|
+
// A launch failure after spawn still has a writer; the caller's stop must await its exit.
|
|
269
|
+
if (!this.child)
|
|
270
|
+
await this.authStore.stop();
|
|
219
271
|
throw error;
|
|
220
272
|
}
|
|
221
273
|
}
|
|
@@ -229,7 +281,6 @@ export class Engine {
|
|
|
229
281
|
return { port: this.port, token: this.token, identityPath };
|
|
230
282
|
if (this.child && this.child.exitCode === null)
|
|
231
283
|
return { port: this.port, token: this.token, identityPath };
|
|
232
|
-
this.stalePid();
|
|
233
284
|
this.state('starting');
|
|
234
285
|
const { entry, env } = this.doctorContext();
|
|
235
286
|
const fd = openSync(join(this.o.stateDir, 'logs', 'openclaw.log'), 'a', 0o600);
|
|
@@ -255,7 +306,7 @@ export class Engine {
|
|
|
255
306
|
if (this.child !== child || this.stopping)
|
|
256
307
|
return;
|
|
257
308
|
this.child = undefined;
|
|
258
|
-
|
|
309
|
+
this.removeOwnedPid(child.pid);
|
|
259
310
|
void (async () => {
|
|
260
311
|
await this.authStore.stop();
|
|
261
312
|
if (this.stopping)
|
|
@@ -275,6 +326,11 @@ export class Engine {
|
|
|
275
326
|
});
|
|
276
327
|
return { port: this.port, token: this.token, identityPath };
|
|
277
328
|
}
|
|
329
|
+
removeOwnedPid(pid) {
|
|
330
|
+
const path = join(this.root, 'gateway.pid');
|
|
331
|
+
if (pid && existsSync(path) && readFileSync(path, 'utf8') === String(pid))
|
|
332
|
+
rmSync(path, { force: true });
|
|
333
|
+
}
|
|
278
334
|
withAuthStore(task) { return this.authStore.offline(task); }
|
|
279
335
|
async stop() {
|
|
280
336
|
this.stopping = true;
|
|
@@ -299,10 +355,9 @@ export class Engine {
|
|
|
299
355
|
if (child && child.exitCode === null && child.signalCode === null)
|
|
300
356
|
throw new Error('engine did not stop; credential store still in use');
|
|
301
357
|
this.child = undefined;
|
|
302
|
-
|
|
358
|
+
this.removeOwnedPid(child?.pid);
|
|
303
359
|
if (!this.credentialsLocked)
|
|
304
360
|
await this.authStore.stop();
|
|
305
|
-
rmSync(this.bridgeSock, { force: true });
|
|
306
361
|
this.state('stopped');
|
|
307
362
|
}
|
|
308
363
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.t
|
|
|
2
2
|
export type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, Member, PlanWindow, Route, RunEnd, RunEvent, RunRef, RunSpec, RunUsage, SignInOptions, SignInView, ToolHost, ToolSpec, } from './types.ts';
|
|
3
3
|
export { OpenClawKit, type KitOptions, type RetainedLogin } from './kit.ts';
|
|
4
4
|
export { stateWords, toAccountView, words, type AccountView, type WordKey } from './words.ts';
|
|
5
|
+
export { EngineAlreadyRunningError } from './engine-status.ts';
|
package/dist/index.js
CHANGED
package/dist/kit.js
CHANGED
|
@@ -503,6 +503,10 @@ export class OpenClawKit {
|
|
|
503
503
|
if (transport && this.transport === transport)
|
|
504
504
|
await this.disconnect();
|
|
505
505
|
await this.engine.stop();
|
|
506
|
+
if (error instanceof Error && 'code' in error && error.code === 'engine-already-running') {
|
|
507
|
+
this.setState({ phase: 'failed', why: 'engine-already-running' });
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
506
510
|
if (needsUpdate)
|
|
507
511
|
this.setState({ phase: 'needs-update', why: 'version' });
|
|
508
512
|
if (!needsUpdate && !this.stopping)
|
package/dist/types.d.ts
CHANGED
|
@@ -115,7 +115,7 @@ export type Decision = {
|
|
|
115
115
|
};
|
|
116
116
|
export type KitState = {
|
|
117
117
|
phase: 'stopped' | 'installing' | 'starting' | 'repairing' | 'ready' | 'restarting' | 'failed' | 'needs-update' | 'locked';
|
|
118
|
-
why?: 'install' | 'handshake' | 'exited' | 'port' | 'version';
|
|
118
|
+
why?: 'install' | 'handshake' | 'exited' | 'port' | 'version' | 'engine-already-running';
|
|
119
119
|
retryAt?: number;
|
|
120
120
|
};
|
|
121
121
|
export type Hello = {
|
package/dist/words.js
CHANGED
|
@@ -19,6 +19,8 @@ const PHASE_WORDS = {
|
|
|
19
19
|
};
|
|
20
20
|
export function stateWords(s) {
|
|
21
21
|
// 'stopped' is never on screen (4.3 covers what a person can see), so 5.14 has no row for it.
|
|
22
|
+
if (s.phase === 'failed' && s.why === 'engine-already-running')
|
|
23
|
+
return words('engine.alreadyRunning');
|
|
22
24
|
return s.phase === 'stopped' ? '' : words(PHASE_WORDS[s.phase]);
|
|
23
25
|
}
|
|
24
26
|
export function toAccountView(view, ready) {
|
package/dist/words.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"engine.locked": "Your saved sign-in is locked. Unlock your password storage, then try again.",
|
|
6
6
|
"engine.ready": "Ready.",
|
|
7
7
|
"engine.restarting": "Something stopped. Starting it again by itself.",
|
|
8
|
+
"engine.alreadyRunning": "Your saved sign-in is in use. Try again after the other session stops.",
|
|
8
9
|
"engine.failed": "This computer couldn't start the helper. Restart the app to try again.",
|
|
9
10
|
"engine.needsUpdate": "This app needs an update to keep working.",
|
|
10
11
|
"member.signedOut": "Sign in with {name} to start.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/openclaw",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|