pi-onlyne 0.3.4 → 0.4.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/README.md CHANGED
@@ -46,23 +46,21 @@ For a one-off run without installing:
46
46
  pi -e npm:pi-onlyne
47
47
  ```
48
48
 
49
- You also need an initialized Onlyne workspace and a running workspace-local daemon:
49
+ You also need an initialized Onlyne workspace:
50
50
 
51
51
  ```bash
52
52
  onlyne init
53
- onlyne run
54
- # Optional, in another shell: refresh the workspace-local agent skill
53
+ # Optional: refresh the workspace-local agent skill
55
54
  onlyne export-skill
56
55
  ```
57
56
 
58
- `pi-onlyne` does not install launchd/systemd jobs and does not spawn a global daemon. If you want background supervision, wrap `onlyne --workspace /path/to/project run` yourself per workspace.
57
+ `pi-onlyne` can manage a workspace-local daemon for the current Pi session. Prefer `/onlyne daemon start|stop|restart` or `/onlyne watch on` over shelling out `onlyne run` manually. Do not combine plugin-managed daemons with ad-hoc `nohup onlyne run`, `pkill -f 'onlyne run'`, or global launchd/systemd jobs for the same workspace.
59
58
 
60
59
  ## Typical workflow
61
60
 
62
61
  1. Initialize/configure Onlyne in your project.
63
- 2. Start that workspace's daemon with `onlyne run`.
64
- 3. Install this Pi extension.
65
- 4. Start watching from pi:
62
+ 2. Install this Pi extension.
63
+ 3. Start the workspace daemon and watch from pi:
66
64
 
67
65
  ```text
68
66
  /onlyne watch on
@@ -74,16 +72,22 @@ When a normal user message arrives through Onlyne, pi receives it as a follow-up
74
72
 
75
73
  ```text
76
74
  /onlyne status
75
+ /onlyne daemon start
76
+ /onlyne daemon stop
77
+ /onlyne daemon restart
77
78
  /onlyne watch on
78
79
  /onlyne watch off
79
80
  /onlyne config auto-start
80
81
  ```
81
82
 
82
- `/onlyne` supports argument completions for `status`, `watch on`, `watch off`, and `config auto-start`.
83
+ `/onlyne` supports argument completions for `status`, `daemon start|stop|restart`, `watch on`, `watch off`, and `config auto-start`.
83
84
 
84
85
  ## Agent tools
85
86
 
86
87
  ```text
88
+ onlyne_daemon_start()
89
+ onlyne_daemon_stop()
90
+ onlyne_daemon_restart()
87
91
  onlyne_reply({ text })
88
92
  onlyne_send({ channelId, text, rawText? })
89
93
  onlyne_broadcast({ targets, text, rawText? })
package/SPEC.md CHANGED
@@ -7,9 +7,9 @@ Pi extension for Onlyne. Onlyne remains a workspace-local IM broker; this extens
7
7
  ## v1 Decisions
8
8
 
9
9
  - Watch is configurable; default manual.
10
- - `/onlyne` provides argument completions for its supported subcommands.
11
- - `watch on` connects only to the workspace-local `.onlyne/run/onlyne.sock`; if unavailable, it tells the user to start `onlyne --workspace <root> run`.
12
- - pi-onlyne never owns or launches the daemon. Users handle launchd/systemd/background scripts outside the extension, per workspace.
10
+ - `/onlyne` provides argument completions for its supported subcommands, including daemon lifecycle commands.
11
+ - `watch on` connects to the workspace-local `.onlyne/run/onlyne.sock`; if unavailable, it starts a Pi-owned workspace daemon.
12
+ - `/onlyne daemon start|stop|restart` is the preferred lifecycle surface. Agents must not use ad-hoc `nohup onlyne run`, `pkill -f 'onlyne run'`, or global launchd/systemd jobs when pi-onlyne owns the daemon.
13
13
  - Inbound events come from Onlyne `subscribe_events`; no polling.
14
14
  - Inbound mode is rule-based: `auto-handle`, `queue-only`, or `muted`.
15
15
  - Outbound defaults to `guarded-explicit`: prefer tool reply, fallback to final text, else send configured error text.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { broadcast, connectDaemon, loopback, markConsumed, sendWithRetry, stopProcess, subscribe } from "./onlyne.js";
3
+ import { broadcast, connectDaemon, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe } from "./onlyne.js";
4
4
  import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
5
5
  import { findWorkspace } from "./workspace.js";
6
6
  const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped" };
@@ -9,29 +9,97 @@ const currentConfig = () => loadConfig(state.cwd);
9
9
  function inboundText(data) { const msg = data?.data?.data ?? data?.data ?? data; const channelId = msg.channel_id ?? msg.channelId; const conversationId = msg.conversation_id ?? msg.conversationId; const messageId = msg.message_id ?? msg.messageId; const text = msg.text ?? msg.content ?? msg.body; return channelId && conversationId && typeof text === "string" ? { channelId, conversationId, messageId, text } : null; }
10
10
  function consumeIfNotified(inbound) { if (state.workspace && inbound.messageId)
11
11
  void markConsumed(state.workspace.socketPath, inbound.messageId).catch(() => { }); }
12
- async function startWatch(pi) { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
13
- throw new Error("current workspace has no .onlyne configuration"); const conn = await connectDaemon(state.workspace); state.owner = conn.owner; state.child = conn.process; state.socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
14
- return; const inbound = inboundText(line); if (!inbound)
15
- return; const mode = inboundModeFor(currentConfig(), inbound.channelId, inbound.conversationId); if (mode === "muted")
16
- return; if (inbound.channelId === "loopback") {
17
- if (mode === "auto-handle")
18
- pi.sendUserMessage(`Onlyne loopback activation${inbound.conversationId ? ` (${inbound.conversationId})` : ""}:\n\n${inbound.text}`, { deliverAs: "followUp" });
19
- consumeIfNotified(inbound);
20
- return;
21
- } if (inbound.text.trim() === "/handshake") {
22
- consumeIfNotified(inbound);
23
- return;
24
- } state.currentInbound = { ...inbound, replied: false, noReply: false, reminders: 0 }; if (mode === "auto-handle") {
25
- pi.sendUserMessage(`Onlyne inbound message from ${inbound.channelId}/${inbound.conversationId}:\n\n${inbound.text}\n\nReply with onlyne_reply, or call onlyne_mark_no_reply if no reply is needed.`, { deliverAs: "followUp" });
26
- consumeIfNotified(inbound);
27
- } }); state.watching = true; return `watching ${state.workspace.root} (${state.owner})`; }
28
- function stopWatch() { state.socket?.destroy(); state.socket = undefined; stopProcess(state.child); state.child = undefined; state.watching = false; state.owner = "stopped"; return "watch stopped"; }
12
+ function clearReminder() { if (state.reminderTimer)
13
+ clearTimeout(state.reminderTimer); state.reminderTimer = undefined; }
14
+ function needsReply(inbound = state.currentInbound) { return !!inbound && !inbound.replied && !inbound.noReply && !!state.workspace; }
15
+ function scheduleReminder(pi, delayMs = 30_000) {
16
+ clearReminder();
17
+ const inbound = state.currentInbound;
18
+ if (!inbound || !needsReply(inbound))
19
+ return;
20
+ state.reminderTimer = setTimeout(() => {
21
+ state.reminderTimer = undefined;
22
+ if (!needsReply(inbound) || state.currentInbound !== inbound)
23
+ return;
24
+ const cfg = currentConfig();
25
+ if (cfg.outbound.defaultReplyMode === "explicit-only")
26
+ return;
27
+ if (cfg.outbound.defaultReplyMode === "guarded-explicit" && inbound.reminders < cfg.outbound.guardedExplicit.reminders) {
28
+ if (inbound.reminders === 0)
29
+ inbound.fallbackText = state.lastValidOutput;
30
+ inbound.reminders++;
31
+ pi.sendUserMessage(`Onlyne reminder ${inbound.reminders}/${cfg.outbound.guardedExplicit.reminders}: reply to ${inbound.channelId}/${inbound.conversationId} with onlyne_reply, or call onlyne_mark_no_reply.`, { deliverAs: "followUp" });
32
+ return;
33
+ }
34
+ void reply(inbound.fallbackText || state.lastValidOutput || cfg.outbound.guardedExplicit.noOutputFallbackText).catch(() => { });
35
+ }, delayMs);
36
+ }
37
+ function scheduleReconnect(pi) {
38
+ if (state.reconnectTimer || !state.watching || !state.workspace)
39
+ return;
40
+ state.reconnectTimer = setTimeout(async () => {
41
+ state.reconnectTimer = undefined;
42
+ if (!state.watching)
43
+ return;
44
+ try {
45
+ await startWatch(pi);
46
+ }
47
+ catch {
48
+ scheduleReconnect(pi);
49
+ }
50
+ }, 1000);
51
+ }
52
+ async function startWatch(pi) {
53
+ state.workspace = findWorkspace(state.cwd);
54
+ if (!state.workspace)
55
+ throw new Error("current workspace has no .onlyne configuration");
56
+ if (state.reconnectTimer)
57
+ clearTimeout(state.reconnectTimer);
58
+ state.reconnectTimer = undefined;
59
+ state.socket?.destroy();
60
+ state.socket = undefined;
61
+ const conn = await connectDaemon(state.workspace);
62
+ state.owner = conn.owner;
63
+ state.child = conn.process;
64
+ const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
65
+ return; const inbound = inboundText(line); if (!inbound)
66
+ return; const mode = inboundModeFor(currentConfig(), inbound.channelId, inbound.conversationId); if (mode === "muted")
67
+ return; if (inbound.channelId === "loopback") {
68
+ if (mode === "auto-handle")
69
+ pi.sendUserMessage(`Onlyne loopback activation${inbound.conversationId ? ` (${inbound.conversationId})` : ""}:\n\n${inbound.text}`, { deliverAs: "followUp" });
70
+ consumeIfNotified(inbound);
71
+ return;
72
+ } if (inbound.text.trim() === "/handshake") {
73
+ consumeIfNotified(inbound);
74
+ return;
75
+ } clearReminder(); state.currentInbound = { ...inbound, replied: false, noReply: false, reminders: 0 }; if (mode === "auto-handle") {
76
+ pi.sendUserMessage(`Onlyne inbound message from ${inbound.channelId}/${inbound.conversationId}:\n\n${inbound.text}\n\nReply with onlyne_reply, or call onlyne_mark_no_reply if no reply is needed.`, { deliverAs: "followUp" });
77
+ consumeIfNotified(inbound);
78
+ } }, () => { if (state.socket === socket)
79
+ scheduleReconnect(pi); });
80
+ state.socket = socket;
81
+ state.watching = true;
82
+ return `watching ${state.workspace.root} (${state.owner})`;
83
+ }
84
+ function stopWatch() { if (state.reconnectTimer)
85
+ clearTimeout(state.reconnectTimer); state.reconnectTimer = undefined; clearReminder(); state.socket?.destroy(); state.socket = undefined; stopProcess(state.child); state.child = undefined; state.watching = false; state.owner = "stopped"; return "watch stopped"; }
86
+ async function startDaemon() { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
87
+ throw new Error("current workspace has no .onlyne configuration"); const conn = await connectDaemon(state.workspace, true); state.owner = conn.owner; state.child = conn.process; return `daemon ${state.owner === "extension" ? "started" : "already running"} for ${state.workspace.root}`; }
88
+ async function stopDaemon() { if (!state.workspace)
89
+ state.workspace = findWorkspace(state.cwd); if (!state.workspace)
90
+ throw new Error("current workspace has no .onlyne configuration"); clearReminder(); state.socket?.destroy(); state.socket = undefined; await shutdownDaemon(state.workspace, state.child); state.child = undefined; state.watching = false; state.owner = "stopped"; return `daemon stopped for ${state.workspace.root}`; }
91
+ async function restartDaemon() { await stopDaemon().catch(() => { }); return startDaemon(); }
29
92
  async function reply(text) { if (!state.workspace)
30
93
  throw new Error("onlyne workspace not found"); const inbound = state.currentInbound; if (!inbound)
31
- throw new Error("no active inbound message"); const res = await sendWithRetry(state.workspace.socketPath, { channelId: inbound.channelId }, text, currentConfig().outbound.retry.attempts); if (res.ok)
32
- inbound.replied = true; return res; }
94
+ throw new Error("no active inbound message"); const res = await sendWithRetry(state.workspace.socketPath, { channelId: inbound.channelId }, text, currentConfig().outbound.retry.attempts); if (res.ok) {
95
+ inbound.replied = true;
96
+ clearReminder();
97
+ } return res; }
33
98
  export default function onlyne(pi) {
34
- pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching; stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; ctx.ui.setStatus("onlyne", state.workspace ? "onlyne: ready" : "onlyne: no .onlyne"); if ((currentConfig().watch.autoStart || resumeWatch) && state.workspace) {
99
+ pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching; if (state.owner === "extension")
100
+ await stopDaemon().catch(() => { });
101
+ else
102
+ stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; ctx.ui.setStatus("onlyne", state.workspace ? "onlyne: ready" : "onlyne: no .onlyne"); if ((currentConfig().watch.autoStart || resumeWatch) && state.workspace) {
35
103
  try {
36
104
  ctx.ui.notify(await startWatch(pi), "info");
37
105
  }
@@ -39,31 +107,20 @@ export default function onlyne(pi) {
39
107
  ctx.ui.notify(String(e), "warning");
40
108
  }
41
109
  } });
42
- pi.on("session_shutdown", async () => { stopWatch(); });
110
+ pi.on("session_shutdown", async () => { if (state.owner === "extension")
111
+ await stopDaemon().catch(() => { });
112
+ else
113
+ stopWatch(); });
43
114
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
44
115
  process.once(sig, () => stopWatch());
45
116
  pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
46
117
  state.lastValidOutput = text; });
47
- pi.on("turn_end", async () => {
48
- const inbound = state.currentInbound;
49
- if (!inbound || inbound.replied || inbound.noReply || !state.workspace)
50
- return;
51
- const cfg = currentConfig();
52
- if (cfg.outbound.defaultReplyMode === "explicit-only")
53
- return;
54
- if (cfg.outbound.defaultReplyMode === "guarded-explicit" && inbound.reminders < cfg.outbound.guardedExplicit.reminders) {
55
- if (inbound.reminders === 0)
56
- inbound.fallbackText = state.lastValidOutput;
57
- inbound.reminders++;
58
- pi.sendUserMessage(`Onlyne reminder ${inbound.reminders}/${cfg.outbound.guardedExplicit.reminders}: reply to ${inbound.channelId}/${inbound.conversationId} with onlyne_reply, or call onlyne_mark_no_reply.`, { deliverAs: "followUp" });
59
- return;
60
- }
61
- await reply(inbound.fallbackText || state.lastValidOutput || cfg.outbound.guardedExplicit.noOutputFallbackText);
62
- });
118
+ pi.on("agent_start", async () => clearReminder());
119
+ pi.on("agent_end", async () => scheduleReminder(pi));
63
120
  pi.registerCommand("onlyne", {
64
121
  description: "Onlyne watch/status/config commands",
65
122
  getArgumentCompletions: (prefix) => {
66
- const commands = ["status", "watch on", "watch off", "config auto-start"];
123
+ const commands = ["status", "watch on", "watch off", "daemon start", "daemon stop", "daemon restart", "config auto-start"];
67
124
  const p = prefix.trimStart();
68
125
  const filtered = commands.filter((c) => c.startsWith(p));
69
126
  return filtered.length ? filtered.map((value) => ({ value, label: value })) : null;
@@ -75,6 +132,12 @@ export default function onlyne(pi) {
75
132
  ctx.ui.notify(await startWatch(pi), "info");
76
133
  else if (cmd === "watch" && sub === "off")
77
134
  ctx.ui.notify(stopWatch(), "info");
135
+ else if (cmd === "daemon" && sub === "start")
136
+ ctx.ui.notify(await startDaemon(), "info");
137
+ else if (cmd === "daemon" && sub === "stop")
138
+ ctx.ui.notify(await stopDaemon(), "info");
139
+ else if (cmd === "daemon" && sub === "restart")
140
+ ctx.ui.notify(await restartDaemon(), "info");
78
141
  else if (cmd === "status")
79
142
  ctx.ui.notify(`onlyne ${state.watching ? "watching" : "stopped"}; owner=${state.owner}; workspace=${state.workspace?.root ?? "none"}`, "info");
80
143
  else if (cmd === "config" && sub === "auto-start") {
@@ -84,13 +147,16 @@ export default function onlyne(pi) {
84
147
  ctx.ui.notify(`autoStart=${cfg.watch.autoStart}`, "info");
85
148
  }
86
149
  else
87
- ctx.ui.notify("usage: /onlyne status | watch on|off | config auto-start", "info");
150
+ ctx.ui.notify("usage: /onlyne status | watch on|off | daemon start|stop|restart | config auto-start", "info");
88
151
  }
89
152
  catch (e) {
90
153
  ctx.ui.notify(e instanceof Error ? e.message : String(e), "error");
91
154
  }
92
155
  },
93
156
  });
157
+ pi.registerTool(defineTool({ name: "onlyne_daemon_start", label: "Onlyne daemon start", description: "Start or connect to the current workspace-local Onlyne daemon managed by pi-onlyne.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await startDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
158
+ pi.registerTool(defineTool({ name: "onlyne_daemon_stop", label: "Onlyne daemon stop", description: "Stop the current workspace-local Onlyne daemon when pi-onlyne manages it, without shelling out to pkill/nohup.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await stopDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
159
+ pi.registerTool(defineTool({ name: "onlyne_daemon_restart", label: "Onlyne daemon restart", description: "Restart the current workspace-local Onlyne daemon through pi-onlyne lifecycle management.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await restartDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
94
160
  pi.registerTool(defineTool({ name: "onlyne_reply", label: "Onlyne reply", description: "Reply with plain text to the current Onlyne inbound message.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await reply(params.text))); } }));
95
161
  pi.registerTool(defineTool({ name: "onlyne_send", label: "Onlyne send", description: "Send Markdown to the channel's configured Onlyne conversation. Set rawText=true only for literal plain text.", parameters: Type.Object({ channelId: Type.String(), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
96
162
  throw new Error("onlyne workspace not found"); const res = await sendWithRetry(state.workspace.socketPath, params, params.text, currentConfig().outbound.retry.attempts, params.rawText ?? false); return textResult(JSON.stringify(res), res); } }));
@@ -98,6 +164,8 @@ export default function onlyne(pi) {
98
164
  throw new Error("onlyne workspace not found"); const cfg = currentConfig(); const results = await broadcast(state.workspace.socketPath, params.targets, params.text, cfg.outbound.retry.attempts, cfg.outbound.retry.concurrency, params.rawText ?? false); return textResult(JSON.stringify({ ok: results.every((r) => r.ok), results }), results); } }));
99
165
  pi.registerTool(defineTool({ name: "onlyne_loopback", label: "Onlyne loopback", description: "Inject a local loopback activation message so scripts can wake the current Pi session. Set rawText=false for Markdown. FIFO alternative: write to .onlyne/channels/loopback/in.", parameters: Type.Object({ text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
100
166
  throw new Error("onlyne workspace not found"); const res = await loopback(state.workspace.socketPath, params.text, params.rawText ?? true); return textResult(JSON.stringify(res), res); } }));
101
- pi.registerTool(defineTool({ name: "onlyne_mark_no_reply", label: "Onlyne no reply", description: "Mark the current Onlyne inbound message as intentionally not replied.", parameters: Type.Object({ reason: Type.Optional(Type.String()) }), executionMode: "parallel", async execute(_id, params) { if (state.currentInbound)
102
- state.currentInbound.noReply = true; return textResult("marked no reply", params); } }));
167
+ pi.registerTool(defineTool({ name: "onlyne_mark_no_reply", label: "Onlyne no reply", description: "Mark the current Onlyne inbound message as intentionally not replied.", parameters: Type.Object({ reason: Type.Optional(Type.String()) }), executionMode: "parallel", async execute(_id, params) { if (state.currentInbound) {
168
+ state.currentInbound.noReply = true;
169
+ clearReminder();
170
+ } return textResult("marked no reply", params); } }));
103
171
  }
package/dist/onlyne.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ChildProcess } from "node:child_process";
1
+ import { type ChildProcess } from "node:child_process";
2
2
  import { type Socket } from "node:net";
3
3
  import type { Workspace } from "./workspace.js";
4
4
  export interface OnlyneRequest {
@@ -19,13 +19,14 @@ export interface SendResult extends SendTarget {
19
19
  error?: string;
20
20
  }
21
21
  export declare function request(socketPath: string, req: OnlyneRequest): Promise<any>;
22
- export declare function subscribe(socketPath: string, onLine: (line: any) => void): Socket;
22
+ export declare function subscribe(socketPath: string, onLine: (line: any) => void, onDisconnect?: () => void): Socket;
23
23
  export declare function waitForSocket(socketPath: string, timeoutMs?: number): Promise<void>;
24
- export declare function connectDaemon(ws: Workspace): Promise<{
25
- owner: "external";
24
+ export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean): Promise<{
25
+ owner: "external" | "extension";
26
26
  process?: ChildProcess;
27
27
  }>;
28
- export declare function stopProcess(_child?: ChildProcess): void;
28
+ export declare function shutdownDaemon(ws: Workspace, child?: ChildProcess): Promise<void>;
29
+ export declare function stopProcess(child?: ChildProcess): void;
29
30
  export declare function loopback(socketPath: string, text: string, rawText?: boolean): Promise<any>;
30
31
  export declare function markConsumed(socketPath: string, messageId: string): Promise<any>;
31
32
  export declare function sendWithRetry(socketPath: string, target: SendTarget, text: string, attempts: number, rawText?: boolean): Promise<SendResult>;
package/dist/onlyne.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { createConnection } from "node:net";
2
3
  export function request(socketPath, req) {
3
4
  return new Promise((resolve, reject) => {
@@ -17,11 +18,17 @@ export function request(socketPath, req) {
17
18
  } });
18
19
  });
19
20
  }
20
- export function subscribe(socketPath, onLine) {
21
+ export function subscribe(socketPath, onLine, onDisconnect) {
21
22
  const socket = createConnection(socketPath);
22
23
  let buf = "";
24
+ let closed = false;
23
25
  socket.setEncoding("utf8");
24
- socket.on("connect", () => socket.write('{"id":"sub","op":"subscribe_events"}\n'));
26
+ const disconnect = () => { if (closed)
27
+ return; closed = true; onDisconnect?.(); };
28
+ socket.on("error", disconnect);
29
+ socket.on("close", disconnect);
30
+ socket.on("connect", () => { if (!socket.destroyed)
31
+ socket.write('{"id":"sub","op":"subscribe_events"}\n', () => { }); });
25
32
  socket.on("data", (chunk) => { buf += chunk; for (;;) {
26
33
  const idx = buf.indexOf("\n");
27
34
  if (idx < 0)
@@ -52,16 +59,56 @@ export async function waitForSocket(socketPath, timeoutMs = 5000) {
52
59
  }
53
60
  throw last instanceof Error ? last : new Error("onlyne socket not ready");
54
61
  }
55
- export async function connectDaemon(ws) {
62
+ function spawnManagedDaemon(ws) {
63
+ const bin = process.env.ONLYNE_BIN || "onlyne";
64
+ const script = `
65
+ parent="$1"; shift
66
+ "$@" &
67
+ child=$!
68
+ trap 'kill "$child" 2>/dev/null; wait "$child" 2>/dev/null' INT TERM HUP EXIT
69
+ while kill -0 "$parent" 2>/dev/null; do
70
+ kill -0 "$child" 2>/dev/null || { wait "$child"; exit $?; }
71
+ sleep 1
72
+ done
73
+ kill "$child" 2>/dev/null
74
+ wait "$child" 2>/dev/null
75
+ `;
76
+ return spawn("sh", ["-c", script, "onlyne-supervisor", String(process.pid), bin, "--workspace", ws.root, "run"], { stdio: "ignore" });
77
+ }
78
+ export async function connectDaemon(ws, startIfMissing = true) {
56
79
  try {
57
80
  await request(ws.socketPath, { id: "ping", op: "ping" });
58
81
  return { owner: "external" };
59
82
  }
60
83
  catch (e) {
61
- throw new Error(`onlyne daemon is not running for ${ws.root}; start it with: onlyne --workspace ${ws.root} run`, { cause: e });
84
+ if (!startIfMissing)
85
+ throw new Error(`onlyne daemon is not running for ${ws.root}; start it with /onlyne daemon start`, { cause: e });
86
+ const child = spawnManagedDaemon(ws);
87
+ try {
88
+ await waitForSocket(ws.socketPath);
89
+ // 自己 spawn 的 daemon 已退出:竞态中输给了其他启动方,socket 归对方所有,降级为 external(只订阅、不 shutdown)。
90
+ if (child.exitCode !== null || child.signalCode !== null)
91
+ return { owner: "external" };
92
+ return { owner: "extension", process: child };
93
+ }
94
+ catch (err) {
95
+ stopProcess(child);
96
+ throw err;
97
+ }
62
98
  }
63
99
  }
64
- export function stopProcess(_child) { }
100
+ export async function shutdownDaemon(ws, child) {
101
+ try {
102
+ await request(ws.socketPath, { id: `shutdown-${Date.now()}`, op: "shutdown" });
103
+ }
104
+ catch { /* may already be down */ }
105
+ stopProcess(child);
106
+ }
107
+ export function stopProcess(child) { if (!child || child.killed)
108
+ return; try {
109
+ child.kill("SIGTERM");
110
+ }
111
+ catch { /* ignore */ } }
65
112
  export async function loopback(socketPath, text, rawText = true) {
66
113
  return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", text, raw_text: rawText });
67
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension tools for sending messages through Onlyne.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",