pi-onlyne 1.1.0 → 1.1.2

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
@@ -79,10 +79,10 @@ printf '{"packages":["../.onlyne/agent/onlyne-agent-pi"]}\n' > <ws>/.pi/settings
79
79
  ### From npm
80
80
 
81
81
  ```bash
82
- pi install pi-onlyne # user-level: every pi process on this box loads it
82
+ pi install npm:pi-onlyne # user-level: every pi process on this box loads it
83
83
  ```
84
84
 
85
- The published package is `pi-onlyne` on npm; `pi install pi-onlyne@<version>` pins
85
+ The published package is `pi-onlyne` on npm; `pi install npm:pi-onlyne@<version>` pins
86
86
  one. This route reaches ordinary interactive sessions too, and there the extension
87
87
  stays inert (no `ONLYNE_ROLE`, so no adapter). A role workspace needs no global
88
88
  install to get a panel: the file-level copy above, or `onlyne server generate`,
@@ -215,10 +215,10 @@ workspace, `relay.toml` in a manual installation.
215
215
 
216
216
  ```toml
217
217
  relay_required = ["writer"] # these roles must have received a handoff
218
- relay_required_count = 2 # ... or this many distinct downstream roles
218
+ relay_required_count = 2 # legacy alias of relay_count: this many distinct downstream roles
219
219
  ```
220
220
 
221
- `relay_required` wins when both keys are present.
221
+ `relay_count` is the canonical count key. `relay_required_count` is its legacy alias, the spelling `relay.toml` itself uses. `relay_required` wins when both the list and the count keys are present.
222
222
 
223
223
  The policy belongs in the spec, not in the vendor directory. `onlyne generate --force`
224
224
  rewrites the copy this package is vendored into and takes a hand-written `relay.toml`
@@ -228,8 +228,8 @@ every session process it spawns:
228
228
  ```toml
229
229
  [[client]]
230
230
  role = "planner"
231
+ relay_count = 2 # this many distinct downstream roles
231
232
  relay_required = ["writer"] # these roles must have received a handoff
232
- relay_count = 2 # ... or this many distinct downstream roles
233
233
  ```
234
234
 
235
235
  The sources rank `environment > relay.toml > none`: `ONLYNE_RELAY_REQUIRED` (the list,
@@ -298,10 +298,12 @@ the shipped client.
298
298
  written, and a framing fault closes the connection and reconnects. Framing cannot
299
299
  resynchronise after a corrupt body, which is the same conclusion
300
300
  `crates/onlyne-frame/src/lib.rs` reaches.
301
- - **Task ids here are single-use.** The plugin acks duplicate `assign` deliveries for the
302
- same task (`reason: "duplicate"`) without a second injection, and remembers the id for
303
- the life of the connection. The client today mints a fresh uuid per task, so this only
304
- ever fires on a genuine redelivery.
301
+ - **Deliveries are idempotent; tasks are not.** The dedup key is the envelope id. The
302
+ same delivery twice gets one injection and an ack with `reason: "duplicate"`, and a
303
+ new envelope for a task that is already running reaches that session as another
304
+ message — the work record keeps its counters and its relay ledger, and only its
305
+ "turns since this instruction" watchdog restarts. The client mints a fresh uuid per
306
+ envelope, so `duplicate` fires on a genuine re-offer and on nothing else.
305
307
 
306
308
  - **Pane binding (Orca tabs).** Inside an Orca pane the plugin reports the pane it runs in on every
307
309
  heartbeat, as `observed.host.orca.pane_key` in the report's `Observation`
@@ -327,7 +329,7 @@ the shipped client.
327
329
  | `ONLYNE_ROLE` | yes | the mount role |
328
330
  | `ONLYNE_SESSION_ID` | yes | mounted session id; `session_id` equals `task_id` in the shipped client |
329
331
  | `ONLYNE_TASK_ID` | yes | the task this process serves; drives `session_register` and the initial `ready` |
330
- | `ONLYNE_SOCKET` | no | overrides the socket path (default `<cwd>/.onlyne/run/s`) |
332
+ | `ONLYNE_SOCKET` | no | the socket the client serves for this workspace, injected into every session process it spawns; with the variable unset the plugin reads the marker `<cwd>/.onlyne/run/socket` for the path the daemon published, and falls back to `<cwd>/.onlyne/run/s` |
331
333
  | `ONLYNE_RELAY_REQUIRED` | no | the role's spec `relay_required`, comma-joined: the guard's list mode (§5) |
332
334
  | `ONLYNE_RELAY_COUNT` | no | the role's spec `relay_count`: the guard's count mode, which decides only when the list is empty (§5) |
333
335
  | `ORCA_PANE_KEY` | no | where this process runs (`<tab_id>:<leaf_id>`), reported on every heartbeat as `observed.host.orca.pane_key`; unset outside an Orca pane, which is why the field is then absent |
@@ -337,9 +339,10 @@ the shipped client.
337
339
  Constants worth knowing: the plugin heartbeats every 10 s (`heartbeat_timeout_ms` is 30 s),
338
340
  allows 5 s for `hello` and 30 s per request, and reconnects on a 1/2/4/8/16/30 s ladder.
339
341
 
340
- The plugin reads two files of its own: `<cwd>/.pi/onlyne.json` (the switch, §1) and
342
+ The plugin reads three files of its own: `<cwd>/.pi/onlyne.json` (the switch, §1),
341
343
  `relay.toml` next to its `package.json` (the relay policy's fallback, read only when the
342
- client injected none, §5).
344
+ client injected none, §5), and `<cwd>/.onlyne/run/socket` (the marker naming the socket
345
+ path the client's daemon bound, read when the environment carried none, §8).
343
346
 
344
347
  ## 8. Troubleshooting
345
348
 
@@ -347,6 +350,7 @@ client injected none, §5).
347
350
  | --- | --- | --- |
348
351
  | `[pi-onlyne] session …` never appears | one of the three env vars is missing, or `enabled` is false | `env \| grep ONLYNE_`; `cat .pi/onlyne.json` |
349
352
  | `socket error: connect ENOENT …/.onlyne/run/s` | no `onlyne-client run` for this workspace | start the client, or `onlyne-client status` |
353
+ | `socket error: connect EINVAL …/.onlyne/run/s` on a deep workspace | macOS gives `sun_path` 104 bytes, so a socket path past 103 is refused; a generated role workspace nests three levels under its server root and a long root carries the canonical spelling over the bound. The client serves such a workspace from a short path under the temporary directory and publishes it in `<workspace>/.onlyne/run/socket` | `onlyne-client status` for the line `onlyne: client running … socket <path>`, which names the served path, plus the client log line carrying `socket = <path>`; `cat <workspace>/.onlyne/run/socket` holds that same path, and the plugin dials it when the environment injected nothing |
350
354
  | `reconnecting in 4000ms` in a loop | the client is down or the socket was replaced | `onlyne --server-root … roles` |
351
355
  | `ready refused: internal: unknown session for …` | the plugin mounted and reported for a task the client never staged (normal when pi is started by hand outside a task) | start pi under the client, not by hand |
352
356
  | `assign` never arrives | the client's `session_command` did not spawn pi, or `inject` was dropped | the client log for the spawn line; `/onlyne status` for the capability set |
@@ -366,7 +370,7 @@ client injected none, §5).
366
370
 
367
371
  ```bash
368
372
  cd plugins/onlyne-agent-pi
369
- node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard
373
+ node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard, socket path
370
374
  ```
371
375
 
372
376
  `src/agent.live.test.mjs` skips itself unless `target/debug/onlyne-client` and
package/README.zh.md CHANGED
@@ -73,10 +73,10 @@ printf '{"packages":["../.onlyne/agent/onlyne-agent-pi"]}\n' > <ws>/.pi/settings
73
73
  ### 从 npm 装
74
74
 
75
75
  ```bash
76
- pi install pi-onlyne # 用户级:这台机器上每个 pi 进程都会加载
76
+ pi install npm:pi-onlyne # 用户级:这台机器上每个 pi 进程都会加载
77
77
  ```
78
78
 
79
- 发布名是 npm 上的 `pi-onlyne`,`pi install pi-onlyne@<version>` 钉住某一版。这条路径会覆盖
79
+ 发布名是 npm 上的 `pi-onlyne`,`pi install npm:pi-onlyne@<version>` 钉住某一版。这条路径会覆盖
80
80
  普通交互会话,那里没有 `ONLYNE_ROLE`,扩展保持静默(见 §1 的身份门)。role workspace 想要面
81
81
  板,不必装到全局:上面那份文件级复制、或者 `onlyne server generate`,都把插件限定在服务这个
82
82
  role 的 workspace 里。
@@ -259,9 +259,10 @@ stderr 告警并忽略,把机会让回文件。
259
259
  `inject` 插件记录的重载。其他键记日志后忽略,绝不误读。
260
260
  - **`frame_too_large` / `bad_frame`**:超限正文在写出任何字节之前就被拒;帧错误关闭连接并重
261
261
  连。帧一旦损坏无法重新同步,这与 `crates/onlyne-frame/src/lib.rs` 的结论一致。
262
- - **任务 id 在连接生命周期内一次性使用**:同一任务的重复 `assign` 插件只回
263
- `reason: "duplicate"` 的 ack,不重复注入,并记住这个 id 直到连接结束。当今 client 每个任务
264
- 都是新 uuid,所以这条只在真正的重投上生效。
262
+ - **投递按 envelope id 幂等,任务不按 id 一次性使用**:去重键是 envelope id。同一条投递重复
263
+ 到达只注入一次,ack 带 `reason: "duplicate"`;正在运行的任务收到新 envelope,会作为新消息
264
+ 注入同一个会话,工作记录保留自己的计数与转发账本,只把"自这条指令以来的轮数"看门狗归零。
265
+ client 每条 envelope 都发新 uuid,所以 `duplicate` 只在真正的重投上生效。
265
266
 
266
267
  - **pane 绑定(Orca tab)。** 在 Orca pane 里,插件在每个 heartbeat 上报自己跑在哪:报告
267
268
  `Observation` 里的 `observed.host.orca.pane_key`(`crates/onlyne-session/src/host.rs`),环境
@@ -283,7 +284,7 @@ stderr 告警并忽略,把机会让回文件。
283
284
  | `ONLYNE_ROLE` | 是 | 挂载的 role |
284
285
  | `ONLYNE_SESSION_ID` | 是 | 挂载的 session id;当前 client 中 session_id 等于 task_id |
285
286
  | `ONLYNE_TASK_ID` | 是 | 本进程服务的任务;驱动 `session_register` 与首条 `ready` |
286
- | `ONLYNE_SOCKET` | 否 | 覆盖 socket 路径(默认 `<cwd>/.onlyne/run/s`) |
287
+ | `ONLYNE_SOCKET` | 否 | client 为该工作区实际服务的 socket 路径;凡 client 拉起的会话进程都会带上。变量未设置时,插件读标记文件 `<cwd>/.onlyne/run/socket`,取守护进程发布的那个路径,随后落到 `<cwd>/.onlyne/run/s` |
287
288
  | `ONLYNE_RELAY_REQUIRED` | 否 | 该 role 在 spec 里的 `relay_required`,逗号分隔:守卫的名单模式(§5) |
288
289
  | `ONLYNE_RELAY_COUNT` | 否 | 该 role 在 spec 里的 `relay_count`:守卫的 count 模式,只在名单为空时起作用(§5) |
289
290
  | `ORCA_PANE_KEY` | 否 | 本进程跑在哪(`<tab_id>:<leaf_id>`),每个 heartbeat 以 `observed.host.orca.pane_key` 上报;不在 Orca pane 里时未设置,这也是该字段缺席的原因 |
@@ -293,8 +294,10 @@ stderr 告警并忽略,把机会让回文件。
293
294
  值得记住的常量:插件每 10 秒发一次心跳(`heartbeat_timeout_ms` 是 30 秒),`hello` 最多等
294
295
  5 秒,单次请求超时 30 秒,重连按 1/2/4/8/16/30 秒阶梯退避。
295
296
 
296
- 插件自己读两个文件:`<cwd>/.pi/onlyne.json`(开关,§1)与 `package.json` 旁边的
297
- `relay.toml`(接力策略的兜底,只在 client 没注入策略时才读,§5)。
297
+ 插件自己读三个文件:`<cwd>/.pi/onlyne.json`(开关,§1)、`package.json` 旁边的
298
+ `relay.toml`(接力策略的兜底,只在 client 没注入策略时才读,§5)、
299
+ `<cwd>/.onlyne/run/socket`(标记文件,写明 client 守护进程绑定的 socket 路径,只在环境里
300
+ 没带路径时才读,§8)。
298
301
 
299
302
  ## 8. 故障排查
300
303
 
@@ -302,6 +305,7 @@ stderr 告警并忽略,把机会让回文件。
302
305
  | --- | --- | --- |
303
306
  | 看不到 `[pi-onlyne] session …` | 三个环境变量缺一,或 `enabled` 为 false | `env \| grep ONLYNE_`;`cat .pi/onlyne.json` |
304
307
  | `socket error: connect ENOENT …/.onlyne/run/s` | 该工作区没有 `onlyne-client run` | 起 client,或 `onlyne-client status` |
308
+ | 深层工作区里 `socket error: connect EINVAL …/.onlyne/run/s` | macOS 的 `sun_path` 只有 104 字节,超过 103 的 socket 路径会被内核拒绝;生成的 role 工作区在 server root 下再套三层,root 一长,规范写法就越过这个上界。client 面对这种工作区会把 socket 放到临时目录下的短路径上服务,并把选中的路径发布进 `<workspace>/.onlyne/run/socket` | 看 `onlyne-client status` 打印的 `onlyne: client running … socket <路径>`,那一行点出实际服务的路径,再看 client 日志里带 `socket = <路径>` 的那行;`cat <workspace>/.onlyne/run/socket` 得到同一个路径——环境里没带变量时,插件拨的就是它 |
305
309
  | 反复 `reconnecting in 4000ms` | client 已停或 socket 被替换 | `onlyne --server-root … roles` |
306
310
  | `ready refused: internal: unknown session for …` | 插件为 client 从未暂存的任务报了 ready(手工起 pi 时的正常现象) | 让 client 拉起 pi,而不是手工起 |
307
311
  | `assign` 一直不来 | client 的 `session_command` 没能拉起 pi,或 `inject` 被降级 | client 日志里的 spawn 行;`/onlyne status` 看能力集 |
@@ -321,7 +325,7 @@ stderr 告警并忽略,把机会让回文件。
321
325
 
322
326
  ```bash
323
327
  cd plugins/onlyne-agent-pi
324
- node --test src/*.test.mjs # 帧编解码、协议词汇、agent 状态机、配置、接力守卫
328
+ node --test src/*.test.mjs # 帧编解码、协议词汇、agent 状态机、配置、接力守卫、socket 路径
325
329
  ```
326
330
 
327
331
  `src/agent.live.test.mjs` 只在 `target/debug/onlyne-client` 与 `onlyne-server` 存在时运行。
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Onlyne agent adapter for pi: the session lifecycle an onlyne role client expects from a pi host.",
5
5
  "type": "module",
6
- "main": "./src/index.ts",
7
6
  "license": "MIT",
8
7
  "repository": {
9
8
  "type": "git",
@@ -32,12 +31,10 @@
32
31
  ]
33
32
  },
34
33
  "scripts": {
35
- "test": "node --test src/*.test.mjs",
36
- "test:live": "node --test src/agent.live.test.mjs"
34
+ "test": "node --test src/*.test.mjs"
37
35
  },
38
36
  "peerDependencies": {
39
37
  "@earendil-works/pi-coding-agent": "*",
40
38
  "typebox": "*"
41
- },
42
- "peerDevDependencies": {}
39
+ }
43
40
  }
@@ -1,13 +1,18 @@
1
- # Onlyne relay guard policy — copy to `relay.toml` next to this package's `package.json`.
1
+ # Manual-installation escape hatch for the relay guard.
2
2
  #
3
- # Manual installations only. A generated workspace states the same policy in the
4
- # server spec's `[[client]]` entry (`relay_required` / `relay_count`), which the
5
- # client injects as `ONLYNE_RELAY_REQUIRED` / `ONLYNE_RELAY_COUNT`. Those
6
- # environment variables win, and a file they shadow is ignored outright.
3
+ # A generated workspace gets its guard policy from spec.toml ([[client]] rows
4
+ # `relay_required = ["writer"]`, `relay_count = 2` — `relay_required_count` is
5
+ # accepted as the guard file's own spelling), which the client injects into
6
+ # every session it spawns. This file is for installations that manage their
7
+ # own workspace: drop it beside package.json and the guard reads it when the
8
+ # environment carries no policy. Environment wins over this file; no policy in
9
+ # either place leaves the guard off.
7
10
  #
8
- # Closed subset: flat `key = value` lines, the two keys below, one-line arrays of
9
- # double-quoted strings, `#` comments. Anything outside that warns on stderr and
10
- # is ignored. `relay_required` wins when both keys are present.
11
+ # relay_required wins over relay_count when both are present.
11
12
 
12
- relay_required = ["writer"] # these roles must have received a handoff
13
- relay_required_count = 2 # ... or this many distinct downstream roles
13
+ # Downstream roles one of this role's sessions must have handed work to before
14
+ # it may report a terminal outcome:
15
+ # relay_required = ["writer", "auditor"]
16
+
17
+ # ... or this many distinct downstream roles:
18
+ # relay_required_count = 2
package/src/agent.mjs CHANGED
@@ -152,7 +152,13 @@ export class OnlyneAgent {
152
152
  * sent — the guard judges this session's own deliveries, not history.
153
153
  */
154
154
  this.deliveredTo = new Set();
155
- this.injectedTasks = new Set();
155
+ /**
156
+ * The deliveries this process has already handed to the model, keyed by
157
+ * envelope id (a delivery's own identity). Keying it by task id would swallow
158
+ * every later envelope for a live task — the follow-up that never arrives —
159
+ * and the guard below answers a true re-delivery with `duplicate`.
160
+ */
161
+ this.injectedDeliveries = new Set();
156
162
  this.deliveredProse = new Set();
157
163
  /** Pushes that arrived before the handshake finished; see `onFrame`. */
158
164
  this.handshaking = false;
@@ -564,13 +570,16 @@ export class OnlyneAgent {
564
570
  this.log("assign carried no task id; ignored");
565
571
  return;
566
572
  }
567
- if (this.injectedTasks.has(taskId)) {
573
+ // The delivery's own identity, so a re-offer of the same message is caught
574
+ // and a genuinely new message for a running task gets through.
575
+ const deliveryId = envelope.id ?? `task:${taskId}`;
576
+ if (this.injectedDeliveries.has(deliveryId)) {
568
577
  this.stats.duplicates += 1;
569
- this.notice("dup", `task ${taskId.slice(0, 8)} already injected`);
578
+ this.notice("dup", `~~ task ${taskId.slice(0, 8)} re-delivered, already injected`);
570
579
  await this.ack(taskId, true, "duplicate");
571
580
  return;
572
581
  }
573
- this.injectedTasks.add(taskId);
582
+ this.injectedDeliveries.add(deliveryId);
574
583
  this.stats.assigns += 1;
575
584
  if (typeof args.generation === "number") this.generation = args.generation;
576
585
 
@@ -580,18 +589,32 @@ export class OnlyneAgent {
580
589
  if (proseIsNew) this.deliveredProse.add(prose);
581
590
  const text = injectionText({ assign: { ...args, task_id: taskId }, proseIsNew, attachmentPaths: attachments.map((item) => item.path) });
582
591
 
583
- this.tasks.set(taskId, {
584
- taskId,
585
- envelopeId: envelope.id ?? null,
586
- // Who handed this task over: the relay guard's count mode does not count
587
- // a send straight back to it (`relay.mjs`).
588
- upstream: envelope.from?.role?.role ?? null,
589
- turnsSinceAssign: 0,
590
- turns: 0,
591
- errored: false,
592
- head: "",
593
- failed: false,
594
- });
592
+ const held = this.tasks.get(taskId);
593
+ // A completed record is not a live one: a new envelope for a task that
594
+ // already settled is fresh work under an old id, so it gets a fresh record.
595
+ if (held && !held.completed) {
596
+ // A new envelope for a task this session already holds — a follow-up, a
597
+ // redirect, a bounce back through a relay. The work record stays where it
598
+ // is: its delivered set keeps the relay guard's count, and its completion
599
+ // state still settles the task. Only the "since this instruction" counter
600
+ // moves, so the settled-without-completing watchdog measures the newest one.
601
+ held.turnsSinceAssign = 0;
602
+ held.envelopeId = envelope.id ?? held.envelopeId;
603
+ if (held.failed) held.failed = false;
604
+ } else {
605
+ this.tasks.set(taskId, {
606
+ taskId,
607
+ envelopeId: envelope.id ?? null,
608
+ // Who handed this task over: the relay guard's count mode does not count
609
+ // a send straight back to it (`relay.mjs`).
610
+ upstream: envelope.from?.role?.role ?? null,
611
+ turnsSinceAssign: 0,
612
+ turns: 0,
613
+ errored: false,
614
+ head: "",
615
+ failed: false,
616
+ });
617
+ }
595
618
  this.agentState = "running";
596
619
  this.surface.wakeUser?.(text, attachments.map((item) => item.part));
597
620
  this.surface.customEntry?.("onlyne-assign", {
@@ -458,6 +458,50 @@ test("an assign is injected once, acked, and a redelivery changes nothing", asyn
458
458
  assert.deepEqual(second[1], { task_id: TASK_ID, accepted: true, reason: "duplicate" });
459
459
  });
460
460
 
461
+ // A follow-up arrives as its own envelope under the task id the session is
462
+ // already running. Keying the injection guard on the task swallowed it: the
463
+ // panel said `already injected`, nothing reached the model, and the row the
464
+ // server kept re-offering never settled.
465
+ test("a new envelope for a running task reaches the model and keeps its record", async () => {
466
+ const { agent, host, surface } = await startAgent();
467
+ agent.start();
468
+ await waitFor(() => host.of("report").length >= 1);
469
+
470
+ host.notify("assign", assignArgs());
471
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
472
+ const record = agent.tasks.get(TASK_ID);
473
+ // Turns already run under this task, without driving the turn hooks: a real
474
+ // turn end also arms the settle fallback, which would complete the task.
475
+ record.turns = 3;
476
+ record.turnsSinceAssign = 2;
477
+
478
+ const base = assignArgs();
479
+ const follow = {
480
+ ...base,
481
+ envelope: {
482
+ ...base.envelope,
483
+ id: "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172",
484
+ body: { text: "actually, use the q8 variant" },
485
+ },
486
+ };
487
+ host.notify("assign", follow);
488
+ await waitFor(() => (surface.calls.wakeUser.length === 2 ? true : null));
489
+ assert.match(surface.calls.wakeUser[1].text, /q8 variant/);
490
+ const acks = await waitFor(() => (host.of("assign_ack").length === 2 ? host.of("assign_ack") : null));
491
+ assert.deepEqual(acks[1], { task_id: TASK_ID, accepted: true });
492
+
493
+ assert.equal(agent.tasks.get(TASK_ID), record, "the running work record survives");
494
+ assert.equal(record.turns, 3, "what the session already did under this task is not erased");
495
+ assert.equal(record.turnsSinceAssign, 0, "the watchdog counts from the newest instruction");
496
+ assert.equal(record.envelopeId, "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172");
497
+
498
+ // The same envelope again is the true duplicate, and it changes nothing.
499
+ host.notify("assign", follow);
500
+ const third = await waitFor(() => (host.of("assign_ack").length === 3 ? host.of("assign_ack") : null));
501
+ assert.equal(surface.calls.wakeUser.length, 2);
502
+ assert.deepEqual(third[2], { task_id: TASK_ID, accepted: true, reason: "duplicate" });
503
+ });
504
+
461
505
  // The live case in crates/onlyne-testkit/e2e/pi-live.sh found this: the client
462
506
  // hands over a staged session by writing the hello reply and the first assign
463
507
  // together, so both frames arrive in one read. The assignment must not be
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import { Type } from "typebox";
20
20
  import { OnlyneAgent } from "./agent.mjs";
21
21
  import { loadConfig, sessionIdentity } from "./config.mjs";
22
22
  import { loadRelay, relayEnabled } from "./relay.mjs";
23
+ import { resolveSocketPath } from "./socket.mjs";
23
24
  import { createSurface } from "./pi-surface.mjs";
24
25
 
25
26
  /**
@@ -32,9 +33,6 @@ declare const process: {
32
33
  stderr: { write(chunk: string): void };
33
34
  };
34
35
 
35
- /** Socket every role workspace serves; `crates/onlyne-client/src/adapter_socket.rs`. */
36
- const SOCKET_RELATIVE_PATH = ".onlyne/run/s";
37
-
38
36
  /** One image part handed to the pi message surface. */
39
37
  interface ImagePartInput {
40
38
  mime: string;
@@ -235,7 +233,9 @@ export default function onlyne(pi: ExtensionAPI) {
235
233
  log(`disabled by ${config.path}`);
236
234
  return;
237
235
  }
238
- const socketPath = env.ONLYNE_SOCKET || `${ctx.cwd}/${SOCKET_RELATIVE_PATH}`;
236
+ // Environment first (the client injects the path it serves), then the
237
+ // marker the daemon publishes, then the canonical `run/s` (socket.mjs).
238
+ const socketPath = resolveSocketPath(env, ctx.cwd);
239
239
  // The guard's policy comes from the spec through the client's environment;
240
240
  // a hand-written `relay.toml` beside the package is the fallback a manual
241
241
  // installation still has (relay.mjs).
package/src/socket.mjs ADDED
@@ -0,0 +1,54 @@
1
+ // The adapter socket a pi session dials.
2
+ //
3
+ // macOS gives `sun_path` 104 bytes, so the kernel refuses a unix socket path
4
+ // past 103 (`UNIX_SOCKET_PATH_MAX` in `crates/onlyne-layout/src/lib.rs`). A
5
+ // generated role workspace nests three levels below its server root
6
+ // (`<root>/.onlyne/ws/<topology>/<role>/.onlyne/run/s`), so a deep root carries
7
+ // the canonical spelling past that bound. The client answers by serving such a
8
+ // workspace from a short path under the temporary directory
9
+ // (`<temp>/onlyne-<16hex>/s`) and publishing the choice it bound in the marker
10
+ // file `<workspace>/.onlyne/run/socket`, one line holding the absolute served
11
+ // path. `onlyne-layout::SocketEndpoint::publish` writes that file; every reader
12
+ // in the product reaches one live socket through it.
13
+ //
14
+ // So the plugin has three answers in order, cheapest first: the path the client
15
+ // injected when it spawned this process (`ONLYNE_SOCKET`), the path the running
16
+ // daemon published in the marker, and the canonical spelling. The third one is
17
+ // a complete answer for every workspace short enough to serve from `run/s`,
18
+ // because `bind_socket` writes the marker at every start and such a tree
19
+ // publishes `run/s` in it; the two readings give one path. A marker that is
20
+ // missing, unreadable, or blank holds no published override, so resolution
21
+ // falls through silently. That keeps a hand-started pi in a workspace with a
22
+ // running daemon on the right socket with no environment at all.
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { isAbsolute, join } from "node:path";
26
+
27
+ /** The canonical socket leaf every role workspace names; `SOCKET_FILE_NAME` in `crates/onlyne-layout/src/lib.rs`. */
28
+ export const SOCKET_RELATIVE_PATH = join(".onlyne", "run", "s");
29
+
30
+ /** Marker beside it naming the path the daemon actually serves. */
31
+ export const SOCKET_MARKER_RELATIVE_PATH = join(".onlyne", "run", "socket");
32
+
33
+ /**
34
+ * The socket path to dial for the workspace at `cwd`.
35
+ *
36
+ * @param {Record<string, string | undefined>} env the process environment
37
+ * @param {string} cwd the pi working directory, the role workspace itself
38
+ * @param {{ readFile?: (path: string) => string }} [options]
39
+ * @returns {string} an absolute path: the injected one, the published one, or `run/s`
40
+ */
41
+ export function resolveSocketPath(env, cwd, options = {}) {
42
+ const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
43
+ const injected = typeof env.ONLYNE_SOCKET === "string" ? env.ONLYNE_SOCKET.trim() : "";
44
+ if (injected) return injected;
45
+ const marker = join(cwd, SOCKET_MARKER_RELATIVE_PATH);
46
+ let published = "";
47
+ try {
48
+ published = readFile(marker).trim();
49
+ } catch {
50
+ published = "";
51
+ }
52
+ if (published && isAbsolute(published)) return published;
53
+ return join(cwd, SOCKET_RELATIVE_PATH);
54
+ }
@@ -0,0 +1,79 @@
1
+ // Which socket path a pi session dials.
2
+ //
3
+ // The three answers have to stay in one order: what the client injected, what
4
+ // the daemon published in `<run>/socket`, and the canonical `<run>/s`. A
5
+ // workspace deep enough that the canonical spelling passes macOS' 103-byte
6
+ // `sun_path` bound is served from a short path under the temporary directory,
7
+ // and the marker is the only place inside the tree that names it.
8
+
9
+ import assert from "node:assert/strict";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterEach, test } from "node:test";
14
+
15
+ import { SOCKET_MARKER_RELATIVE_PATH, SOCKET_RELATIVE_PATH, resolveSocketPath } from "./socket.mjs";
16
+
17
+ const cleanups = [];
18
+ afterEach(() => {
19
+ while (cleanups.length > 0) cleanups.pop()();
20
+ });
21
+
22
+ /** One temp workspace whose `run/` directory exists, optionally with a marker body. */
23
+ function workspace(markerBody) {
24
+ const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-socket-"));
25
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
26
+ mkdirSync(join(dir, ".onlyne", "run"), { recursive: true });
27
+ if (markerBody !== undefined) writeFileSync(join(dir, SOCKET_MARKER_RELATIVE_PATH), markerBody);
28
+ return dir;
29
+ }
30
+
31
+ /** The short path a daemon serves an over-long workspace from. */
32
+ const SERVED = join(tmpdir(), "onlyne-0123456789abcdef", "s");
33
+
34
+ test("the injected environment variable decides first", () => {
35
+ const dir = workspace(`${SERVED}\n`);
36
+ assert.equal(resolveSocketPath({ ONLYNE_SOCKET: SERVED }, dir), SERVED);
37
+ // Any other published answer loses to the environment.
38
+ assert.equal(resolveSocketPath({ ONLYNE_SOCKET: join(dir, SOCKET_RELATIVE_PATH) }, dir), join(dir, SOCKET_RELATIVE_PATH));
39
+ // A value wrapped in space names the same socket.
40
+ assert.equal(resolveSocketPath({ ONLYNE_SOCKET: ` ${SERVED} ` }, dir), SERVED);
41
+
42
+ // A variable holding nothing answers nothing, so the tree decides.
43
+ const bare = workspace();
44
+ assert.equal(resolveSocketPath({ ONLYNE_SOCKET: " " }, bare), join(bare, SOCKET_RELATIVE_PATH));
45
+ assert.equal(resolveSocketPath({}, bare), join(bare, SOCKET_RELATIVE_PATH));
46
+ });
47
+
48
+ test("a published marker moves the session onto the served path", () => {
49
+ const dir = workspace(`${SERVED}\n`);
50
+ const resolved = resolveSocketPath({}, dir);
51
+ assert.equal(resolved, SERVED);
52
+ // The moved path lives outside the workspace, which is the whole point of the
53
+ // marker: the tree's own `run/s` would be refused by the kernel here.
54
+ assert.ok(!resolved.startsWith(dir));
55
+ });
56
+
57
+ test("a workspace with no marker keeps the canonical spelling", () => {
58
+ const dir = workspace();
59
+ assert.equal(resolveSocketPath({}, dir), join(dir, ".onlyne", "run", "s"));
60
+ });
61
+
62
+ test("a marker that is empty, relative, or unreadable falls through silently", () => {
63
+ const empty = workspace("");
64
+ assert.equal(resolveSocketPath({}, empty), join(empty, SOCKET_RELATIVE_PATH));
65
+
66
+ const relative = workspace("run/s");
67
+ assert.equal(resolveSocketPath({}, relative), join(relative, SOCKET_RELATIVE_PATH));
68
+
69
+ // A `socket` leaf holding a directory makes the read itself fail.
70
+ const unreadable = workspace();
71
+ rmSync(join(unreadable, SOCKET_MARKER_RELATIVE_PATH), { force: true });
72
+ mkdirSync(join(unreadable, SOCKET_MARKER_RELATIVE_PATH));
73
+ assert.equal(resolveSocketPath({}, unreadable), join(unreadable, SOCKET_RELATIVE_PATH));
74
+
75
+ // A workspace with no `run/` at all still names one canonical path.
76
+ const bare = mkdtempSync(join(tmpdir(), "pi-onlyne-socket-"));
77
+ cleanups.push(() => rmSync(bare, { recursive: true, force: true }));
78
+ assert.equal(resolveSocketPath({}, bare), join(bare, SOCKET_RELATIVE_PATH));
79
+ });