mindwire 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -61,6 +61,79 @@ setTimeout(() => run.cancel(), 5_000);
61
61
  for await (const ev of run) { /* … */ }
62
62
  ```
63
63
 
64
+ ## Saved workspace data
65
+
66
+ `mw.workspace` manages named agent profiles, projects and chat links in the
67
+ workspace's SQLite registry. It is shared by every `withAgent()` view. Harness
68
+ configuration/history remain in their existing harness stores; credentials still
69
+ use the daemon's existing auth adapters and credential store.
70
+
71
+ ```ts
72
+ const profileId = crypto.randomUUID();
73
+ const projectId = crypto.randomUUID();
74
+ const chatId = crypto.randomUUID();
75
+ await mw.workspace.agents.put(profileId, { name: "My Codex", agentType: "codex" });
76
+ // This metadata API opens an existing directory. See createProject below for cloning.
77
+ await mw.workspace.projects.put(projectId, { name: "App", path: "/work/app" });
78
+ const saved = await mw.workspace.chats.put(chatId, {
79
+ agentId: profileId, projectId, title: "Build app",
80
+ });
81
+
82
+ // A second client can reconstruct all saved navigation links.
83
+ const snapshot = await mw.workspace.snapshot();
84
+ const updates = await mw.workspace.changes(saved.revision, saved.workspaceId);
85
+ const project = snapshot.projects.find(p => p.id === projectId)!;
86
+ await mw.workspace.projects.put(projectId, { ...project, name: "Renamed" }, project.revision);
87
+ ```
88
+
89
+ Use stable IDs when retrying creates and the latest record revision when updating
90
+ or deleting. Conflicts return `ApiError` with status 409; a removed ID returns 410.
91
+ Collection deletion removes membership and dependent chat links, retaining files
92
+ and native transcripts. Use `deleteChat()` for an explicit transcript purge.
93
+
94
+ Check `health().workspaceMetadataVersion` before enabling registry features on an
95
+ older daemon. `workspace.import()` imports legacy IDs once; existing records and
96
+ deletion markers win. See the [registry contract](../../daemon/WORKSPACES.md) for
97
+ migration and recovery. The embedded Go SDK exposes the same operations through
98
+ `client.Workspace`, using the same SQLite implementation.
99
+
100
+ Project workflows run in the daemon, including directory validation, clone output,
101
+ cancellation, retries, registration and folder removal. Check `health().projectOperationsVersion`.
102
+
103
+ ```ts
104
+ const operation = await mw.workspace.createProject({
105
+ id: crypto.randomUUID(), // retain this ID when retrying a lost acknowledgement
106
+ source: "clone", // or "folder" (existing) / "create" (new directory)
107
+ name: "My app",
108
+ path: "~/projects/my-app", // parent exists; destination must be absent for cloning
109
+ repoUrl: "https://github.com/owner/repo.git",
110
+ // auth: { kind: "token", token: installationToken } — write-only, never in origin
111
+ });
112
+ for await (const snapshot of mw.workspace.operations.watch(operation.id)) {
113
+ console.log(snapshot.status, snapshot.phase, snapshot.progress);
114
+ }
115
+ // A new client can list/observe the same operation, including after disconnect.
116
+ await mw.workspace.operations.list(true);
117
+ // Explicit cancellation/retry; breaking a watch loop only detaches that observer.
118
+ // await mw.workspace.operations.cancel(operation.id);
119
+ // await mw.workspace.operations.retry(operation.id, freshAuth);
120
+ ```
121
+
122
+ In Go, use `client.Workspace.CreateProject(ProjectRequest{...})` and
123
+ `client.Workspace.Operations.Get/List/Watch/Cancel/Retry`. These call the same
124
+ project service as HTTP. Active destination reservations prevent duplicate clones;
125
+ the final project and successful operation status commit in one SQLite transaction.
126
+ An interrupted daemon reconciles a staged commit or marks earlier work retryable.
127
+
128
+ `workspace.projects.delete(id, revision)` removes registry membership while retaining
129
+ files and native transcripts. Explicit folder deletion uses
130
+ `workspace.removeProjectFiles(id, { operationId, expectedRevision })` (Go:
131
+ `Workspace.RemoveProjectFiles`). Confirm it with the user first, retain the operation
132
+ ID for transport retries, and observe the returned operation as above. The daemon
133
+ blocks running chats/overlapping projects and recovers removal across restarts.
134
+ Cancellation returns 409 after the `deleting` phase; cleanup then affects only the
135
+ owned quarantine, even if someone recreates the original directory.
136
+
64
137
  ## Targets — where the daemon runs
65
138
 
66
139
  **One `new Mindwire` = one instance = one daemon = one environment.** Every agent that instance runs
package/dist/client.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Http, type FetchLike } from "./http.js";
2
2
  import { Run } from "./run.js";
3
+ import { WorkspaceApi } from "./workspace.js";
3
4
  import { type Target } from "./target/index.js";
4
5
  import type { EnsureEvent } from "./target/host.js";
5
6
  import type { AgentInfo, AuthMethod, AuthState, AuthStatus, Catalog, ChatSummary, CustomProvider, DeleteResult, DoctorReport, Health, MCPServer, MemoryDoc, MemoryScope, Message, ModelInfo, Notification, NotifyChannel, NotifyChannelInput, NotifyChannelTestResult, NotifyConfigInput, NotifyConfigStatus, NotifyRule, NotifyRuleInput, ProcessFrame, PromptTemplate, ResolveOptions, SetupStatus, Stats, Subagent, TurnOptions } from "./types.js";
@@ -67,6 +68,8 @@ export interface AgentScoped {
67
68
  * ```
68
69
  */
69
70
  export declare class Mindwire {
71
+ /** Workspace registry: saved agent profiles, projects and chat relationships. */
72
+ readonly workspace: WorkspaceApi;
70
73
  readonly http: Http;
71
74
  /** The default agent type applied to agent-scoped calls, if set. */
72
75
  readonly defaultAgent: string | undefined;
package/dist/index.cjs CHANGED
@@ -289,6 +289,8 @@ async function* readSSE(body, signal) {
289
289
  if (payload !== void 0) yield JSON.parse(payload);
290
290
  } finally {
291
291
  if (signal) signal.removeEventListener("abort", onAbort);
292
+ await reader.cancel().catch(() => {
293
+ });
292
294
  reader.releaseLock();
293
295
  }
294
296
  }
@@ -413,9 +415,9 @@ var Run = class _Run {
413
415
  });
414
416
  }
415
417
  /**
416
- * Switch the permission mode of the live turn (e.g. `default`, `acceptEdits`, `plan`,
417
- * `bypassPermissions`). Only meaningful on a persistent (non-bypass) turn; on a one-shot turn it
418
- * is a best-effort no-op. Requires the agent's `setPermissionMode` capability.
418
+ * Switch the live permission mode using a value from the agent's settings schema.
419
+ * Resolves after the harness acknowledges the change; rejects if it cannot apply it.
420
+ * Requires `setPermissionMode`. Codex settings apply on the next turn instead.
419
421
  */
420
422
  async setPermissionMode(mode) {
421
423
  await this.http.request("POST", `/runs/${encodeURIComponent(this.id)}/set-permission-mode`, {
@@ -475,8 +477,114 @@ var Run = class _Run {
475
477
  }
476
478
  };
477
479
 
480
+ // src/workspace.ts
481
+ var ProjectOperationsApi = class {
482
+ constructor(mw) {
483
+ this.mw = mw;
484
+ }
485
+ mw;
486
+ async list(activeOnly = false) {
487
+ const response = await this.mw.http.request("GET", "/workspace/operations", {
488
+ query: { active: activeOnly }
489
+ });
490
+ return response.operations;
491
+ }
492
+ get(id) {
493
+ return this.mw.http.request("GET", `/workspace/operations/${encodeURIComponent(id)}`);
494
+ }
495
+ cancel(id) {
496
+ return this.mw.http.request("POST", `/workspace/operations/${encodeURIComponent(id)}/cancel`);
497
+ }
498
+ retry(id, auth) {
499
+ return this.mw.http.request("POST", `/workspace/operations/${encodeURIComponent(id)}/retry`, { body: { auth } });
500
+ }
501
+ /** The first event is the current snapshot, then live changes. Reconnecting never replays old
502
+ * progress. Breaking the loop/aborting detaches the observer; cancel(id) explicitly stops work.
503
+ */
504
+ async *watch(id, opts = {}) {
505
+ const controller = new AbortController();
506
+ const abort = () => controller.abort();
507
+ opts.signal?.addEventListener("abort", abort, { once: true });
508
+ if (opts.signal?.aborted) controller.abort();
509
+ try {
510
+ const response = await this.mw.http.open("GET", `/workspace/operations/${encodeURIComponent(id)}/stream`, {
511
+ signal: controller.signal
512
+ });
513
+ let sequence = -1;
514
+ for await (const operation of readSSE(response.body, controller.signal)) {
515
+ if (operation.sequence > sequence) {
516
+ sequence = operation.sequence;
517
+ yield operation;
518
+ }
519
+ }
520
+ } finally {
521
+ controller.abort();
522
+ opts.signal?.removeEventListener("abort", abort);
523
+ }
524
+ }
525
+ };
526
+ var WorkspaceCollection = class {
527
+ constructor(mw, kind) {
528
+ this.mw = mw;
529
+ this.kind = kind;
530
+ }
531
+ mw;
532
+ kind;
533
+ /** Create with a stable client-generated ID. For updates, supply the record's last revision. */
534
+ put(id, record, expectedRevision) {
535
+ return this.mw.http.request("PUT", `/workspace/${this.kind}/${encodeURIComponent(id)}`, {
536
+ body: { record, expectedRevision }
537
+ });
538
+ }
539
+ /** Remove membership and dependent chat links. Files and native transcripts are retained.
540
+ * Use deleteChat() for an explicit transcript purge. Running chats reject removal with 409.
541
+ */
542
+ delete(id, revision) {
543
+ return this.mw.http.request("DELETE", `/workspace/${this.kind}/${encodeURIComponent(id)}`, {
544
+ query: { revision }
545
+ });
546
+ }
547
+ };
548
+ var WorkspaceApi = class {
549
+ constructor(mw) {
550
+ this.mw = mw;
551
+ this.operations = new ProjectOperationsApi(mw);
552
+ this.agents = new WorkspaceCollection(mw, "agents");
553
+ this.projects = new WorkspaceCollection(mw, "projects");
554
+ this.chats = new WorkspaceCollection(mw, "chats");
555
+ }
556
+ mw;
557
+ operations;
558
+ agents;
559
+ projects;
560
+ chats;
561
+ snapshot() {
562
+ return this.mw.http.request("GET", "/workspace");
563
+ }
564
+ /** Start an operation owned by the daemon. The same ID/payload returns the existing operation. */
565
+ createProject(request) {
566
+ return this.mw.http.request("POST", "/workspace/projects", { body: request });
567
+ }
568
+ /** Permanently remove the confirmed project's directory and membership.
569
+ * projects.delete() retains files. Native harness transcripts are not purged.
570
+ */
571
+ removeProjectFiles(id, request) {
572
+ return this.mw.http.request("POST", `/workspace/projects/${encodeURIComponent(id)}/remove`, { body: request });
573
+ }
574
+ /** Incremental reconciliation. Pass the previous identity to detect a replaced/restored workspace.
575
+ * A 409 requires fetching snapshot() again; never apply a delta to a different registry.
576
+ */
577
+ changes(since, workspaceId) {
578
+ return this.mw.http.request("GET", "/workspace/changes", { query: { since, workspaceId } });
579
+ }
580
+ /** Import legacy metadata before replacing a local cache. Safe to repeat after interruption. */
581
+ import(records) {
582
+ return this.mw.http.request("POST", "/workspace/import", { body: records });
583
+ }
584
+ };
585
+
478
586
  // src/version.ts
479
- var SDK_VERSION = "0.1.11" ;
587
+ var SDK_VERSION = "0.1.13" ;
480
588
 
481
589
  // src/daemon-binary.ts
482
590
  function supported(platform, arch) {
@@ -734,6 +842,8 @@ function remote(baseUrl, opts = {}) {
734
842
  // src/client.ts
735
843
  var handleByTransport = /* @__PURE__ */ new WeakMap();
736
844
  var Mindwire = class _Mindwire {
845
+ /** Workspace registry: saved agent profiles, projects and chat relationships. */
846
+ workspace;
737
847
  http;
738
848
  /** The default agent type applied to agent-scoped calls, if set. */
739
849
  defaultAgent;
@@ -767,6 +877,7 @@ var Mindwire = class _Mindwire {
767
877
  }
768
878
  });
769
879
  this.defaultAgent = opts.agent;
880
+ this.workspace = new WorkspaceApi(this);
770
881
  this.auth = new AuthApi(this);
771
882
  this.prompts = new PromptsApi(this);
772
883
  this.mcp = new McpApi(this);
@@ -787,6 +898,7 @@ var Mindwire = class _Mindwire {
787
898
  const clone = Object.create(_Mindwire.prototype);
788
899
  clone.http = this.http;
789
900
  clone.defaultAgent = agent;
901
+ clone.workspace = new WorkspaceApi(clone);
790
902
  clone.auth = new AuthApi(clone);
791
903
  clone.prompts = new PromptsApi(clone);
792
904
  clone.mcp = new McpApi(clone);
@@ -1309,17 +1421,24 @@ function makeEmit(cfg) {
1309
1421
  }
1310
1422
  };
1311
1423
  }
1312
- var DAEMON_DIR = "/root/.mindwire";
1313
- var BIN = `${DAEMON_DIR}/mindwired`;
1314
- var BIN_NEW = `${BIN}.new`;
1315
- var STATE = `${DAEMON_DIR}/agent-state.json`;
1316
- var LOG = `${DAEMON_DIR}/daemon.log`;
1424
+ async function daemonDirectory(host) {
1425
+ const result = await host.exec(["sh", "-lc", 'printf "<<MW_HOME>>%s<<MW_HOME>>" "$HOME"'], { timeoutSeconds: 15 });
1426
+ const runtimeHome = result.stdout?.match(/<<MW_HOME>>([\s\S]*?)<<MW_HOME>>/)?.[1];
1427
+ if (!runtimeHome?.startsWith("/") || runtimeHome.length > 4096 || /[\x00-\x1f\x7f]/.test(runtimeHome)) {
1428
+ throw new MindwireError("mindwire: cannot resolve the runtime user's home directory");
1429
+ }
1430
+ return runtimeHome.replace(/\/+$/, "") + "/.mindwire";
1431
+ }
1317
1432
  async function ensureDaemon(host, cfg) {
1318
1433
  const emit2 = makeEmit(cfg);
1319
- const token = cfg.token ?? (await import('crypto')).randomBytes(32).toString("hex");
1434
+ let token = cfg.token ?? (await import('crypto')).randomBytes(32).toString("hex");
1320
1435
  try {
1321
1436
  await waitHostReady(host);
1322
1437
  emit2({ phase: "connect", message: "runtime ready" });
1438
+ const directory = await daemonDirectory(host);
1439
+ if (!cfg.token) {
1440
+ token = await readWorkspaceToken(host, directory) ?? token;
1441
+ }
1323
1442
  const desired = cfg.desiredVersion ?? SDK_VERSION;
1324
1443
  const health = await probeHealth(host, cfg.port, token);
1325
1444
  if (health.reachable) {
@@ -1340,22 +1459,35 @@ async function ensureDaemon(host, cfg) {
1340
1459
  } else {
1341
1460
  emit2({ phase: "probe", message: "no daemon reachable; deploying" });
1342
1461
  }
1343
- await deploy(host, cfg, emit2, token);
1462
+ await deploy(host, cfg, emit2, token, directory);
1463
+ if (!cfg.token) token = await readWorkspaceToken(host, directory) ?? token;
1344
1464
  return token;
1345
1465
  } catch (err) {
1346
1466
  emit2({ phase: "error", message: "ensure failed", error: err instanceof Error ? err.message : String(err) });
1347
1467
  throw err;
1348
1468
  }
1349
1469
  }
1350
- async function deploy(host, cfg, emit2, token) {
1470
+ async function readWorkspaceToken(host, directory) {
1471
+ const saved = await host.exec(["sh", "-lc", `cat ${shellQuote(directory + "/daemon.token")} 2>/dev/null || true`], { timeoutSeconds: 15 });
1472
+ const candidate = saved.stdout?.trim();
1473
+ return candidate && candidate.length <= 4096 && /^[\x21-\x7e]+$/.test(candidate) ? candidate : void 0;
1474
+ }
1475
+ async function deploy(host, cfg, emit2, token, directory) {
1476
+ const newPath = directory + "/mindwired.new";
1477
+ const BIN = shellQuote(directory + "/mindwired"), BIN_NEW = shellQuote(newPath);
1478
+ const STATE = shellQuote(directory + "/agent-state.json"), LOG = shellQuote(directory + "/daemon.log");
1479
+ const TOKEN = shellQuote(directory + "/daemon.token");
1351
1480
  const arch = await probeArch(host);
1352
1481
  const desired = cfg.desiredVersion ?? SDK_VERSION;
1353
1482
  let acquire = "";
1483
+ let stagedUpload;
1354
1484
  if (cfg.daemonBin) {
1355
1485
  const binPath = await resolveLinuxDaemon(cfg.daemonBin, arch);
1356
1486
  const bytes = await readBytes(binPath);
1357
1487
  emit2({ phase: "upload", message: `uploading daemon (${arch}, ${formatMiB(bytes.length)})`, arch, bytes: bytes.length });
1358
- await host.putFile(BIN_NEW, bytes, { mode: "0755" });
1488
+ stagedUpload = `${newPath}-${(await import('crypto')).randomUUID()}`;
1489
+ await host.putFile(stagedUpload, bytes, { mode: "0755" });
1490
+ acquire = `mv -f ${shellQuote(stagedUpload)} ${BIN_NEW}`;
1359
1491
  } else {
1360
1492
  if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(desired)) {
1361
1493
  throw new MindwireError(`mindwire: cannot download daemon for non-release SDK version ${desired}`);
@@ -1386,25 +1518,46 @@ async function deploy(host, cfg, emit2, token) {
1386
1518
  }
1387
1519
  const script = [
1388
1520
  "set -e",
1389
- `mkdir -p ${DAEMON_DIR}`,
1521
+ `mkdir -p ${shellQuote(directory)}`,
1522
+ // iOS takes this same workspace lock. Unique staging also protects concurrent local uploads.
1523
+ stagedUpload ? `trap ${shellQuote(`rm -f ${shellQuote(stagedUpload)}`)} EXIT` : "",
1524
+ 'command -v flock >/dev/null 2>&1 || { echo "MINDWIRE_FAIL flock is required to lock daemon updates"; exit 1; }',
1525
+ `exec 9>${shellQuote(directory + "/daemon-install.lock")}`,
1526
+ 'flock -w 360 9 || { echo "MINDWIRE_FAIL another daemon update is still running"; exit 1; }',
1527
+ `mw_token=${shellQuote(token)}`,
1528
+ !cfg.token ? `if [ -s ${TOKEN} ]; then mw_token=$(cat ${TOKEN}); fi` : "",
1529
+ !cfg.forceDeploy ? [
1530
+ `mw_health=$(curl -fsS --max-time 3 -H "Authorization: Bearer $mw_token" http://127.0.0.1:${cfg.port}/healthz 2>/dev/null || true)`,
1531
+ `mw_version=$(printf '%s' "$mw_health" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\\([^" ]*\\)".*/\\1/p')`,
1532
+ `if [ -n "$mw_health" ] && ${cfg.autoUpdate ? `[ "$mw_version" = ${shellQuote(desired)} ]` : "true"}; then echo MINDWIRE_READY; exit 0; fi`
1533
+ ].join("\n") : "",
1390
1534
  acquire,
1391
1535
  // Stop a prior daemon by exact process NAME, never `pkill -f <path>`: this whole script (which
1392
1536
  // contains `${BIN}` several times) is the argv of the `bash -lc` shell running it, so a full-cmdline
1393
1537
  // match would SIGTERM our own deploying shell before the daemon ever launches. `-x mindwired` matches
1394
1538
  // only the daemon's comm (`bash`/`pkill` never match), leaving this shell alive.
1395
- `pkill -x mindwired 2>/dev/null || true`,
1539
+ "if command -v pkill >/dev/null 2>&1; then",
1540
+ " pkill -x mindwired 2>/dev/null || true",
1541
+ "else",
1542
+ " for mw_proc in /proc/[0-9]*/comm; do",
1543
+ ' IFS= read -r mw_name 2>/dev/null < "$mw_proc" || continue',
1544
+ ' [ "$mw_name" = mindwired ] || continue',
1545
+ " mw_pid=${mw_proc#/proc/}; mw_pid=${mw_pid%/comm}",
1546
+ ' kill "$mw_pid" 2>/dev/null || true',
1547
+ " done",
1548
+ "fi",
1396
1549
  "sleep 0.3",
1397
1550
  `mv -f ${BIN_NEW} ${BIN}`,
1398
1551
  `chmod +x ${BIN}`,
1399
1552
  // Detach so the daemon survives this exec's shell exiting. ADDR=":<port>" binds 0.0.0.0.
1400
- `setsid nohup env ADDR=":${cfg.port}" AGENT_TYPE="${cfg.agent}" AGENT_CWD="${cfg.agentCwd}" STATE_PATH="${STATE}" DAEMON_TOKEN=${shellQuote(token)} ${BIN} > ${LOG} 2>&1 < /dev/null &`,
1553
+ `setsid nohup env ADDR=":${cfg.port}" AGENT_TYPE=${shellQuote(cfg.agent)} AGENT_CWD=${shellQuote(cfg.agentCwd)} STATE_PATH=${STATE} DAEMON_TOKEN="$mw_token" ${BIN} > ${LOG} 2>&1 < /dev/null 9>&- &`,
1401
1554
  // Health-poll from inside the VM (loopback) and emit a marker — exit codes are unreliable here.
1402
- `for i in $(seq 1 60); do curl -fsS --max-time 2 -H ${shellQuote(`Authorization: Bearer ${token}`)} http://127.0.0.1:${cfg.port}/healthz >/dev/null 2>&1 && { echo MINDWIRE_READY; exit 0; }; sleep 0.25; done`,
1555
+ `for i in $(seq 1 60); do curl -fsS --max-time 2 -H "Authorization: Bearer $mw_token" http://127.0.0.1:${cfg.port}/healthz >/dev/null 2>&1 && { echo MINDWIRE_READY; exit 0; }; sleep 0.25; done`,
1403
1556
  "echo MINDWIRE_FAIL",
1404
1557
  `tail -n 40 ${LOG} 2>/dev/null || true`
1405
1558
  ].join("\n");
1406
1559
  emit2({ phase: "launch", message: "launching daemon" });
1407
- const res = await host.exec(["bash", "-lc", script], { timeoutSeconds: 45 });
1560
+ const res = await host.exec(["bash", "-lc", script], { timeoutSeconds: 420 });
1408
1561
  const out = res.stdout ?? "";
1409
1562
  if (!out.includes("MINDWIRE_READY")) {
1410
1563
  throw new MindwireError(
@@ -2318,12 +2471,15 @@ exports.McpApi = McpApi;
2318
2471
  exports.Mindwire = Mindwire;
2319
2472
  exports.MindwireError = MindwireError;
2320
2473
  exports.NotifyApi = NotifyApi;
2474
+ exports.ProjectOperationsApi = ProjectOperationsApi;
2321
2475
  exports.PromptsApi = PromptsApi;
2322
2476
  exports.ProvidersApi = ProvidersApi;
2323
2477
  exports.Run = Run;
2324
2478
  exports.RunFailedError = RunFailedError;
2325
2479
  exports.SDK_VERSION = SDK_VERSION;
2326
2480
  exports.TimeoutError = TimeoutError;
2481
+ exports.WorkspaceApi = WorkspaceApi;
2482
+ exports.WorkspaceCollection = WorkspaceCollection;
2327
2483
  exports.catalogModels = catalogModels;
2328
2484
  exports.catalogProvider = catalogProvider;
2329
2485
  exports.catalogProviders = catalogProviders;