@lawaty/lacode 0.9.0 → 0.10.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/CHANGELOG.md +68 -1
- package/README.md +5 -3
- package/dist/index.js +74 -13
- package/dist/lacode.js +83 -23
- package/dist/tunnel.js +69 -20
- package/docs/TUNNEL.md +86 -16
- package/docs/USER-STORIES.md +9 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,72 @@ Versions come from git release commits; the npm version is bumped in the same
|
|
|
8
8
|
commit as the changelog entry it describes.
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.10.0] — a notification waits for you instead of timing out, and a permission can be answered from the popup
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **A permission prompt can be answered from the notification itself.** The
|
|
16
|
+
plugin reads the permission request id off opencode's `permission.asked`
|
|
17
|
+
event and puts it in the helper's environment (`LACODE_REQUEST_ID`), so the
|
|
18
|
+
notification carries three buttons: **Allow once**, **Always allow** and
|
|
19
|
+
**Deny**. Their names are opencode's own reply values — `once`, `always` and
|
|
20
|
+
`reject` ("reject" is the server's word for deny) — so a click is already a
|
|
21
|
+
valid answer and nothing has to be translated on the way.
|
|
22
|
+
|
|
23
|
+
The answer travels back over the same path the notification came. The desktop
|
|
24
|
+
end is an ssh forced command, so its stdout comes back down the ssh
|
|
25
|
+
connection and the plugin reads the chosen button's name off the helper's
|
|
26
|
+
stdout — no new key, no listener, no HTTP server. The hop was one-way until
|
|
27
|
+
now, and what made it two-way was a channel that was already there: the
|
|
28
|
+
forced command's stdout is carried to whoever opened the connection, so the
|
|
29
|
+
answer rides home on the connection that is already open. The plugin then
|
|
30
|
+
answers the permission through opencode's own API, and a permission that was
|
|
31
|
+
already answered in the terminal is recognised rather than reported as a
|
|
32
|
+
failure.
|
|
33
|
+
|
|
34
|
+
The wire payload is six newline-delimited fields on both wires —
|
|
35
|
+
`title\nmessage\nurgency\nicon\nsound\nrequestID`, the request id last — and a
|
|
36
|
+
payload with fewer than six fields carries no request id, so an older sender
|
|
37
|
+
still works.
|
|
38
|
+
|
|
39
|
+
Two limits, stated plainly. The scope is **permissions only**: a question is
|
|
40
|
+
not answerable this way, because opencode's v1 SDK client — the one this
|
|
41
|
+
plugin has — has no question API at all, and the v2 routes need a bound TCP
|
|
42
|
+
socket that the default opencode TUI does not have. And the buttons are
|
|
43
|
+
**Linux only**: `osascript` and `terminal-notifier` have no action support,
|
|
44
|
+
so a macOS desktop still gets a plain notice, and the answer there still has
|
|
45
|
+
to be given in the terminal.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **A desktop notification now waits for you instead of timing out on its own.**
|
|
50
|
+
`notify-send` is called with `--expire-time=0` — the freedesktop "never
|
|
51
|
+
expire" timeout — at all three call sites: `sendLocal` in
|
|
52
|
+
`src/tunnel/index.ts`, the tunnel's forced command
|
|
53
|
+
(`bin/oc-notify-receiver`), and `send_notify` in `bin/oc`. Without it the
|
|
54
|
+
desktop daemon's own timer dismissed the popup by itself: measured on KDE
|
|
55
|
+
Plasma 6.7.4, a normal-urgency notification is closed with reason 1
|
|
56
|
+
("expired") after about five seconds, so a notice the user had not acted on
|
|
57
|
+
within that window was simply gone — the difference between a notice you can
|
|
58
|
+
still act on and one you missed. A notification now stays on the desktop
|
|
59
|
+
until you close it.
|
|
60
|
+
|
|
61
|
+
Nothing else about it changed: the `--urgency` values are untouched (a
|
|
62
|
+
question or a permission prompt is deliberately not `critical`, so it does
|
|
63
|
+
not escalate to a modal on most desktops), and the whitelists and the
|
|
64
|
+
cooldown behaviour are exactly as they were.
|
|
65
|
+
|
|
66
|
+
### Documented
|
|
67
|
+
|
|
68
|
+
- **The npm publish notice does not say which dist-tag a release lands on.** npm
|
|
69
|
+
12 prints "Publishing to https://registry.npmjs.org/ with tag latest" for every
|
|
70
|
+
publish, including one whose `publishConfig.tag` sends it to `beta`: the
|
|
71
|
+
message reports the CLI's default, not the resolved tag. 0.9.0 was published
|
|
72
|
+
that way and landed on `beta`, with `latest` still at 0.4.2. The registry is
|
|
73
|
+
the authority, not the notice — after publishing, read
|
|
74
|
+
`npm view @lawaty/lacode dist-tags` before assuming anything about where the
|
|
75
|
+
release went.
|
|
76
|
+
|
|
11
77
|
## [0.9.0] — the cartographer keeps an active backlog, and the model stops naming slot variants
|
|
12
78
|
|
|
13
79
|
### Added
|
|
@@ -464,7 +530,8 @@ experimental; `latest` remains 0.4.2.
|
|
|
464
530
|
|
|
465
531
|
- First published release: pooled subagents over a shared set of models.
|
|
466
532
|
|
|
467
|
-
[Unreleased]: https://github.com/lawaty/lacode/compare/v0.
|
|
533
|
+
[Unreleased]: https://github.com/lawaty/lacode/compare/v0.10.0...HEAD
|
|
534
|
+
[0.10.0]: https://github.com/lawaty/lacode/compare/v0.9.0...v0.10.0
|
|
468
535
|
[0.9.0]: https://github.com/lawaty/lacode/compare/v0.8.0...v0.9.0
|
|
469
536
|
[0.8.0]: https://github.com/lawaty/lacode/compare/v0.7.1...v0.8.0
|
|
470
537
|
[0.7.1]: https://github.com/lawaty/lacode/compare/v0.7.0...v0.7.1
|
package/README.md
CHANGED
|
@@ -346,9 +346,11 @@ lacode doctor # what the environment reso
|
|
|
346
346
|
```
|
|
347
347
|
|
|
348
348
|
`lacode notify` is fire-and-forget and always exits 0 — an unreachable desktop
|
|
349
|
-
must never be able to fail the process that tried to notify you.
|
|
350
|
-
|
|
351
|
-
|
|
349
|
+
must never be able to fail the process that tried to notify you. The one
|
|
350
|
+
exception is an actionable notification — one carrying a permission request id
|
|
351
|
+
— which waits for the click, because the answer is the whole point of it.
|
|
352
|
+
`lacode forward HOST -- COMMAND` runs a command on another host over ssh and
|
|
353
|
+
exits with its status.
|
|
352
354
|
|
|
353
355
|
The full CLI is `notify`, `forward`, `doctor`, `version`, `update`, `receive`
|
|
354
356
|
and `tunnel setup`. `version` reports which copy of LaCode is running — and
|
package/dist/index.js
CHANGED
|
@@ -2832,6 +2832,8 @@ var helperArgv = (helper, title, message, urgency, icon, sound) => {
|
|
|
2832
2832
|
const name = helper.split(/[\\/]/).pop() ?? "";
|
|
2833
2833
|
return name === "lacode" || name === "lacode.js" ? ["notify", title, message, urgency, icon, sound] : [title, message, urgency, icon, sound];
|
|
2834
2834
|
};
|
|
2835
|
+
var PERMISSION_REPLIES = ["once", "always", "reject"];
|
|
2836
|
+
var REQUEST_ID_ENV = "LACODE_REQUEST_ID";
|
|
2835
2837
|
var safeStringify = (value) => {
|
|
2836
2838
|
try {
|
|
2837
2839
|
return JSON.stringify(value) ?? void 0;
|
|
@@ -2901,12 +2903,16 @@ var shouldNotify = (input) => {
|
|
|
2901
2903
|
if (outcome === "done" && input.awaitingResponse) {
|
|
2902
2904
|
return { notify: false, reason: "awaiting your response" };
|
|
2903
2905
|
}
|
|
2904
|
-
|
|
2906
|
+
const notification = {
|
|
2905
2907
|
notify: true,
|
|
2906
2908
|
title: `opencode \u2014 ${title}`,
|
|
2907
2909
|
message: notifyMessage(outcome, detail),
|
|
2908
2910
|
urgency: outcome === "error" ? "critical" : "normal"
|
|
2909
2911
|
};
|
|
2912
|
+
if (outcome === "permission" && input.requestID) {
|
|
2913
|
+
return { ...notification, actionable: { requestID: input.requestID } };
|
|
2914
|
+
}
|
|
2915
|
+
return notification;
|
|
2910
2916
|
};
|
|
2911
2917
|
var questionHeadline = (questions) => {
|
|
2912
2918
|
const first = questions?.find((entry) => typeof entry?.question === "string" && entry.question.trim());
|
|
@@ -2987,6 +2993,7 @@ var SERVICE3 = "notify";
|
|
|
2987
2993
|
var COOLDOWN_MS2 = 5e3;
|
|
2988
2994
|
var MAX_INFLIGHT_HELPERS = 4;
|
|
2989
2995
|
var HELPER_TIMEOUT_MS = 3e4;
|
|
2996
|
+
var ACTION_TIMEOUT_MS = 10 * 6e4 + 3e4;
|
|
2990
2997
|
var MAX_TRACKED_SESSIONS = 500;
|
|
2991
2998
|
var SOUND_ENV = "LACODE_SOUND";
|
|
2992
2999
|
var SOUND_OFF_VALUES = ["0", "false", "off", "no"];
|
|
@@ -3026,11 +3033,50 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3026
3033
|
};
|
|
3027
3034
|
const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
|
|
3028
3035
|
});
|
|
3036
|
+
const answerPermission = async (sessionID, requestID, action) => {
|
|
3037
|
+
const response = PERMISSION_REPLIES.find((candidate) => candidate === action);
|
|
3038
|
+
if (!response) {
|
|
3039
|
+
void log("warn", `notification action ignored: ${JSON.stringify(action)} is not a permission reply`, {
|
|
3040
|
+
sessionID,
|
|
3041
|
+
requestID
|
|
3042
|
+
});
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
const errorStatus = (error) => {
|
|
3046
|
+
const candidate = error;
|
|
3047
|
+
const status2 = candidate?.response?.status ?? candidate?.cause?.status ?? candidate?.status;
|
|
3048
|
+
return typeof status2 === "number" ? status2 : void 0;
|
|
3049
|
+
};
|
|
3050
|
+
const reply = await client.postSessionIdPermissionsPermissionId({
|
|
3051
|
+
path: { id: sessionID, permissionID: requestID },
|
|
3052
|
+
body: { response }
|
|
3053
|
+
}).then((result) => ({ result })).catch((error) => ({ error }));
|
|
3054
|
+
const status = "result" in reply ? reply.result.response?.status : errorStatus(reply.error);
|
|
3055
|
+
if (status === 404) {
|
|
3056
|
+
void log("debug", "permission already answered", { sessionID, requestID });
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
if ("error" in reply) {
|
|
3060
|
+
void log("warn", `permission reply failed: ${errorDetail(reply.error) ?? String(reply.error)}`, {
|
|
3061
|
+
sessionID,
|
|
3062
|
+
requestID
|
|
3063
|
+
});
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
if (reply.result.error) {
|
|
3067
|
+
void log("warn", `permission reply failed: ${errorDetail(reply.result.error) ?? String(reply.result.error)}`, {
|
|
3068
|
+
sessionID,
|
|
3069
|
+
requestID
|
|
3070
|
+
});
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
void log("info", "permission answered from the notification", { sessionID, requestID, response });
|
|
3074
|
+
};
|
|
3029
3075
|
const fetchInfo = async (sessionID) => {
|
|
3030
3076
|
const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
|
|
3031
3077
|
return info;
|
|
3032
3078
|
};
|
|
3033
|
-
const deliverViaHelper = (title, message, urgency, sound) => new Promise((resolve3) => {
|
|
3079
|
+
const deliverViaHelper = (title, message, urgency, sound, requestID) => new Promise((resolve3) => {
|
|
3034
3080
|
if (inflight >= maxInflight) {
|
|
3035
3081
|
void log("warn", `notification dropped: ${inflight} helpers already in flight`, { title });
|
|
3036
3082
|
resolve3({ ok: false, detail: `too many notification helpers in flight (${inflight})` });
|
|
@@ -3045,7 +3091,13 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3045
3091
|
resolve3({ ok: false, detail: "no oc-notify helper found" });
|
|
3046
3092
|
return;
|
|
3047
3093
|
}
|
|
3048
|
-
const
|
|
3094
|
+
const actionable = Boolean(requestID);
|
|
3095
|
+
const timeoutMs = actionable ? ACTION_TIMEOUT_MS : helperTimeoutMs;
|
|
3096
|
+
const child = actionable ? spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
|
|
3097
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3098
|
+
detached: false,
|
|
3099
|
+
env: { ...process.env, [REQUEST_ID_ENV]: requestID }
|
|
3100
|
+
}) : spawn(helper, helperArgv(helper, title, message, urgency, "utilities-terminal", sound), {
|
|
3049
3101
|
stdio: ["ignore", "ignore", "pipe"],
|
|
3050
3102
|
detached: true
|
|
3051
3103
|
});
|
|
@@ -3064,11 +3116,15 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3064
3116
|
child.kill("SIGKILL");
|
|
3065
3117
|
} catch {
|
|
3066
3118
|
}
|
|
3067
|
-
void log("warn", `notification helper timed out after ${
|
|
3068
|
-
finish({ ok: false, detail: `helper timed out after ${
|
|
3069
|
-
},
|
|
3119
|
+
void log("warn", `notification helper timed out after ${timeoutMs}ms`, { title });
|
|
3120
|
+
finish({ ok: false, detail: `helper timed out after ${timeoutMs}ms` });
|
|
3121
|
+
}, timeoutMs);
|
|
3070
3122
|
timer.unref?.();
|
|
3123
|
+
let stdout = "";
|
|
3071
3124
|
let stderr = "";
|
|
3125
|
+
child.stdout?.on("data", (chunk) => {
|
|
3126
|
+
if (stdout.length < 400) stdout += String(chunk);
|
|
3127
|
+
});
|
|
3072
3128
|
child.stderr?.on("data", (chunk) => {
|
|
3073
3129
|
if (stderr.length < 400) stderr += String(chunk);
|
|
3074
3130
|
});
|
|
@@ -3085,12 +3141,13 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3085
3141
|
message
|
|
3086
3142
|
});
|
|
3087
3143
|
}
|
|
3088
|
-
|
|
3144
|
+
const action = actionable ? stdout.trim() : "";
|
|
3145
|
+
finish({ ok, detail: noise || void 0, ...action ? { action } : {} });
|
|
3089
3146
|
});
|
|
3090
|
-
child.unref();
|
|
3147
|
+
if (!actionable) child.unref();
|
|
3091
3148
|
});
|
|
3092
3149
|
const deliver = deps.deliver ?? deliverViaHelper;
|
|
3093
|
-
const announce = async (sessionID, outcome, detail) => {
|
|
3150
|
+
const announce = async (sessionID, outcome, detail, requestID) => {
|
|
3094
3151
|
try {
|
|
3095
3152
|
const info = await fetchInfo(sessionID);
|
|
3096
3153
|
if (!info) {
|
|
@@ -3106,7 +3163,8 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3106
3163
|
sawMessage: active.has(sessionID),
|
|
3107
3164
|
// Either kind of block suppresses the idle event's "finished": a session
|
|
3108
3165
|
// parked on a permission prompt has not finished, it is waiting.
|
|
3109
|
-
awaitingResponse: pending.has(sessionID) || permissions.has(sessionID)
|
|
3166
|
+
awaitingResponse: pending.has(sessionID) || permissions.has(sessionID),
|
|
3167
|
+
requestID
|
|
3110
3168
|
});
|
|
3111
3169
|
if (!decision.notify) {
|
|
3112
3170
|
void log("debug", `suppressed: ${decision.reason}`, { sessionID });
|
|
@@ -3124,8 +3182,10 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3124
3182
|
}
|
|
3125
3183
|
const message = isRemote ? `${decision.message} (remote)` : decision.message;
|
|
3126
3184
|
const sound = soundOn && NOTIFY_OUTCOMES.includes(outcome) ? outcome : "none";
|
|
3127
|
-
const sent = await deliver(decision.title, message, decision.urgency, sound);
|
|
3128
|
-
|
|
3185
|
+
const sent = await deliver(decision.title, message, decision.urgency, sound, requestID);
|
|
3186
|
+
if (sent.action && requestID) await answerPermission(sessionID, requestID, sent.action);
|
|
3187
|
+
const answered = Boolean(sent.action);
|
|
3188
|
+
void log(answered || sent.ok ? "info" : "error", answered ? "permission answered from the notification" : sent.ok ? "notified" : "notification failed", {
|
|
3129
3189
|
sessionID,
|
|
3130
3190
|
outcome,
|
|
3131
3191
|
title: decision.title,
|
|
@@ -3169,7 +3229,8 @@ var notifyHooks = (async ({ client, directory }, options, deps = {}) => {
|
|
|
3169
3229
|
if (!sessionID2) return;
|
|
3170
3230
|
permissions.add(sessionID2);
|
|
3171
3231
|
rememberActive(sessionID2);
|
|
3172
|
-
|
|
3232
|
+
const requestID = typeof properties?.id === "string" && properties.id ? properties.id : void 0;
|
|
3233
|
+
void announce(sessionID2, "permission", permissionHeadline(properties) || void 0, requestID);
|
|
3173
3234
|
return;
|
|
3174
3235
|
}
|
|
3175
3236
|
if (event.type === "permission.replied") {
|
package/dist/lacode.js
CHANGED
|
@@ -76,15 +76,21 @@ var safeText = (raw, fallback, max) => {
|
|
|
76
76
|
};
|
|
77
77
|
var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
|
|
78
78
|
var safeMessage = (raw) => safeText(raw ?? "", "", 400);
|
|
79
|
+
var safeRequestId = (raw) => {
|
|
80
|
+
const clean = (raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
81
|
+
if (!clean) return void 0;
|
|
82
|
+
return clean.length > 128 ? clean.slice(0, 128) : clean;
|
|
83
|
+
};
|
|
79
84
|
var FIELD_SEPARATOR = "\n";
|
|
80
85
|
function parsePayload(raw) {
|
|
81
|
-
const [title, message, urgency, icon, sound] = raw.split(FIELD_SEPARATOR);
|
|
86
|
+
const [title, message, urgency, icon, sound, requestID] = raw.split(FIELD_SEPARATOR);
|
|
82
87
|
return {
|
|
83
88
|
title: safeTitle(title),
|
|
84
89
|
message: safeMessage(message),
|
|
85
90
|
urgency: normalizeUrgency(urgency),
|
|
86
91
|
icon: normalizeIcon(icon),
|
|
87
|
-
sound: normalizeSound(sound)
|
|
92
|
+
sound: normalizeSound(sound),
|
|
93
|
+
requestID: safeRequestId(requestID)
|
|
88
94
|
};
|
|
89
95
|
}
|
|
90
96
|
var encodePayload = (request) => [
|
|
@@ -92,7 +98,10 @@ var encodePayload = (request) => [
|
|
|
92
98
|
safeMessage(request.message),
|
|
93
99
|
normalizeUrgency(request.urgency),
|
|
94
100
|
normalizeIcon(request.icon),
|
|
95
|
-
normalizeSound(request.sound)
|
|
101
|
+
normalizeSound(request.sound),
|
|
102
|
+
// The sixth field, empty when there is no request id: an absent id and an
|
|
103
|
+
// empty one mean the same thing, and the field count never changes.
|
|
104
|
+
safeRequestId(request.requestID) ?? ""
|
|
96
105
|
].join(FIELD_SEPARATOR);
|
|
97
106
|
var SSH_OPTS = [
|
|
98
107
|
"-o",
|
|
@@ -175,6 +184,13 @@ var NO_CHANNEL = {
|
|
|
175
184
|
linux: "notify-send is not installed",
|
|
176
185
|
darwin: "neither osascript nor terminal-notifier is installed"
|
|
177
186
|
};
|
|
187
|
+
var NEVER_EXPIRE_FLAG = "--expire-time=0";
|
|
188
|
+
var NOTIFICATION_ACTIONS = [
|
|
189
|
+
["once", "Allow once"],
|
|
190
|
+
["always", "Always allow"],
|
|
191
|
+
["reject", "Deny"]
|
|
192
|
+
];
|
|
193
|
+
var ACTION_WAIT_MS = 10 * 6e4;
|
|
178
194
|
function sendLocal(request, platform = process.platform) {
|
|
179
195
|
const sound = normalizeSound(request.sound);
|
|
180
196
|
const playSound = () => {
|
|
@@ -195,35 +211,66 @@ function sendLocal(request, platform = process.platform) {
|
|
|
195
211
|
}
|
|
196
212
|
const title = safeTitle(request.title);
|
|
197
213
|
const body = safeMessage(request.message);
|
|
214
|
+
const requestID = safeRequestId(request.requestID);
|
|
215
|
+
const actionable = channel === "notify-send" && requestID !== void 0;
|
|
216
|
+
const actionArgs = actionable ? NOTIFICATION_ACTIONS.map(([name, label]) => `--action=${name}=${label}`) : [];
|
|
198
217
|
const args = channel === "osascript" ? ["-e", DARWIN_SCRIPT, DARWIN_ARGV_MARKER, title, body] : channel === "terminal-notifier" ? ["-title", title, "-message", body, "-appName", "LaCode", "-priority", normalizeUrgency(request.urgency)] : (
|
|
199
|
-
//
|
|
200
|
-
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}
|
|
218
|
+
// Never auto-dismissed: the notification waits for the user to close it.
|
|
219
|
+
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...actionArgs]
|
|
201
220
|
);
|
|
202
|
-
const result = run(CHANNEL_BINARY[channel], args, 5e3);
|
|
221
|
+
const result = run(CHANNEL_BINARY[channel], args, actionable ? ACTION_WAIT_MS : 5e3);
|
|
203
222
|
playSound();
|
|
204
|
-
|
|
223
|
+
const action = actionable ? result.stdout.trim() : "";
|
|
224
|
+
return {
|
|
225
|
+
ok: result.ok,
|
|
226
|
+
detail: result.ok ? void 0 : result.stderr || result.detail,
|
|
227
|
+
transport: "local",
|
|
228
|
+
...action ? { action } : {}
|
|
229
|
+
};
|
|
205
230
|
}
|
|
206
231
|
function sendRemote(request, tunnel) {
|
|
207
232
|
if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
|
|
208
233
|
const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
|
|
234
|
+
const actionable = safeRequestId(request.requestID) !== void 0;
|
|
235
|
+
const timeoutMs = actionable ? Math.max(tunnel.connectTimeoutMs + 2e3, ACTION_WAIT_MS + 2e3) : tunnel.connectTimeoutMs + 2e3;
|
|
236
|
+
const deliver = (args) => {
|
|
237
|
+
const result = run("ssh", args, timeoutMs);
|
|
238
|
+
const action = actionable ? result.stdout.trim() : "";
|
|
239
|
+
return {
|
|
240
|
+
ok: result.ok,
|
|
241
|
+
detail: result.ok ? void 0 : result.stderr || result.detail,
|
|
242
|
+
transport: "remote",
|
|
243
|
+
...action ? { action } : {}
|
|
244
|
+
};
|
|
245
|
+
};
|
|
209
246
|
if (tunnel.host) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
247
|
+
return deliver([
|
|
248
|
+
...SSH_OPTS,
|
|
249
|
+
...CONTROL_OPTS,
|
|
250
|
+
...connect,
|
|
251
|
+
"-i",
|
|
252
|
+
tunnel.key,
|
|
253
|
+
`${tunnel.user}@${tunnel.host}`,
|
|
254
|
+
"--",
|
|
255
|
+
encodePayload(request)
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
return deliver([
|
|
259
|
+
...SSH_OPTS,
|
|
260
|
+
...connect,
|
|
261
|
+
"-i",
|
|
262
|
+
tunnel.key,
|
|
263
|
+
"-p",
|
|
264
|
+
String(tunnel.port),
|
|
265
|
+
`${tunnel.user}@localhost`,
|
|
266
|
+
"--",
|
|
267
|
+
encodePayload(request)
|
|
268
|
+
]);
|
|
223
269
|
}
|
|
224
270
|
function notify(request, env = process.env, platform = process.platform) {
|
|
225
271
|
try {
|
|
226
|
-
|
|
272
|
+
const result = isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
|
|
273
|
+
return result.action ? { ...result, action: result.action } : result;
|
|
227
274
|
} catch (error) {
|
|
228
275
|
return { ok: false, detail: String(error), transport: "none" };
|
|
229
276
|
}
|
|
@@ -639,6 +686,8 @@ var USAGE = `lacode \u2014 portable notifications and remote exec for LaCode
|
|
|
639
686
|
permission | error; an omitted or unrecognised SOUND is none, so a
|
|
640
687
|
four-argument caller simply gets no sound. Fire-and-forget: always
|
|
641
688
|
exits 0.
|
|
689
|
+
With LACODE_REQUEST_ID set, the notification carries buttons and the
|
|
690
|
+
name of the clicked one is printed on stdout.
|
|
642
691
|
|
|
643
692
|
lacode forward HOST -- COMMAND [ARG...]
|
|
644
693
|
Run COMMAND on HOST over ssh. Exits with the remote's status.
|
|
@@ -666,7 +715,9 @@ Environment:
|
|
|
666
715
|
OC_LOCAL_USER desktop user the tunnel authenticates as
|
|
667
716
|
OC_TUNNEL_PORT reverse-forward listener port (default 10022)
|
|
668
717
|
OC_NOTIFY_KEY dedicated key (default ~/.ssh/lacode_notify)
|
|
669
|
-
OC_CONNECT_TIMEOUT_MS / OC_SSH_TIMEOUT_MS
|
|
718
|
+
OC_CONNECT_TIMEOUT_MS / OC_SSH_TIMEOUT_MS
|
|
719
|
+
LACODE_REQUEST_ID makes a notification actionable: it carries buttons, and
|
|
720
|
+
the clicked one is printed on stdout`;
|
|
670
721
|
var argv = process.argv.slice(2);
|
|
671
722
|
var command = argv.shift();
|
|
672
723
|
switch (command) {
|
|
@@ -684,8 +735,16 @@ switch (command) {
|
|
|
684
735
|
console.error(USAGE);
|
|
685
736
|
process.exit(2);
|
|
686
737
|
}
|
|
687
|
-
const result = notify({
|
|
738
|
+
const result = notify({
|
|
739
|
+
title: rawTitle,
|
|
740
|
+
message,
|
|
741
|
+
urgency,
|
|
742
|
+
icon,
|
|
743
|
+
sound: normalizeSound(sound),
|
|
744
|
+
requestID: process.env.LACODE_REQUEST_ID
|
|
745
|
+
});
|
|
688
746
|
if (!result.ok) console.error(`lacode: notification not delivered via ${result.transport}: ${result.detail ?? "unknown reason"}`);
|
|
747
|
+
if (result.action) console.log(result.action);
|
|
689
748
|
process.exit(0);
|
|
690
749
|
break;
|
|
691
750
|
}
|
|
@@ -765,6 +824,7 @@ ${report.note}`);
|
|
|
765
824
|
const request = parsePayload(process.env.SSH_ORIGINAL_COMMAND ?? "");
|
|
766
825
|
const result = sendLocal(request);
|
|
767
826
|
if (!result.ok) console.error(`lacode receive: ${result.detail ?? "not delivered"}`);
|
|
827
|
+
if (result.action) console.log(result.action);
|
|
768
828
|
process.exit(result.ok ? 0 : 1);
|
|
769
829
|
break;
|
|
770
830
|
}
|
package/dist/tunnel.js
CHANGED
|
@@ -69,15 +69,21 @@ var safeText = (raw, fallback, max) => {
|
|
|
69
69
|
};
|
|
70
70
|
var safeTitle = (raw) => safeText(raw ?? "", "LaCode", 120);
|
|
71
71
|
var safeMessage = (raw) => safeText(raw ?? "", "", 400);
|
|
72
|
+
var safeRequestId = (raw) => {
|
|
73
|
+
const clean = (raw ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
74
|
+
if (!clean) return void 0;
|
|
75
|
+
return clean.length > 128 ? clean.slice(0, 128) : clean;
|
|
76
|
+
};
|
|
72
77
|
var FIELD_SEPARATOR = "\n";
|
|
73
78
|
function parsePayload(raw) {
|
|
74
|
-
const [title, message, urgency, icon, sound] = raw.split(FIELD_SEPARATOR);
|
|
79
|
+
const [title, message, urgency, icon, sound, requestID] = raw.split(FIELD_SEPARATOR);
|
|
75
80
|
return {
|
|
76
81
|
title: safeTitle(title),
|
|
77
82
|
message: safeMessage(message),
|
|
78
83
|
urgency: normalizeUrgency(urgency),
|
|
79
84
|
icon: normalizeIcon(icon),
|
|
80
|
-
sound: normalizeSound(sound)
|
|
85
|
+
sound: normalizeSound(sound),
|
|
86
|
+
requestID: safeRequestId(requestID)
|
|
81
87
|
};
|
|
82
88
|
}
|
|
83
89
|
var encodePayload = (request) => [
|
|
@@ -85,7 +91,10 @@ var encodePayload = (request) => [
|
|
|
85
91
|
safeMessage(request.message),
|
|
86
92
|
normalizeUrgency(request.urgency),
|
|
87
93
|
normalizeIcon(request.icon),
|
|
88
|
-
normalizeSound(request.sound)
|
|
94
|
+
normalizeSound(request.sound),
|
|
95
|
+
// The sixth field, empty when there is no request id: an absent id and an
|
|
96
|
+
// empty one mean the same thing, and the field count never changes.
|
|
97
|
+
safeRequestId(request.requestID) ?? ""
|
|
89
98
|
].join(FIELD_SEPARATOR);
|
|
90
99
|
var SSH_OPTS = [
|
|
91
100
|
"-o",
|
|
@@ -168,6 +177,13 @@ var NO_CHANNEL = {
|
|
|
168
177
|
linux: "notify-send is not installed",
|
|
169
178
|
darwin: "neither osascript nor terminal-notifier is installed"
|
|
170
179
|
};
|
|
180
|
+
var NEVER_EXPIRE_FLAG = "--expire-time=0";
|
|
181
|
+
var NOTIFICATION_ACTIONS = [
|
|
182
|
+
["once", "Allow once"],
|
|
183
|
+
["always", "Always allow"],
|
|
184
|
+
["reject", "Deny"]
|
|
185
|
+
];
|
|
186
|
+
var ACTION_WAIT_MS = 10 * 6e4;
|
|
171
187
|
function sendLocal(request, platform = process.platform) {
|
|
172
188
|
const sound = normalizeSound(request.sound);
|
|
173
189
|
const playSound = () => {
|
|
@@ -188,35 +204,66 @@ function sendLocal(request, platform = process.platform) {
|
|
|
188
204
|
}
|
|
189
205
|
const title = safeTitle(request.title);
|
|
190
206
|
const body = safeMessage(request.message);
|
|
207
|
+
const requestID = safeRequestId(request.requestID);
|
|
208
|
+
const actionable = channel === "notify-send" && requestID !== void 0;
|
|
209
|
+
const actionArgs = actionable ? NOTIFICATION_ACTIONS.map(([name, label]) => `--action=${name}=${label}`) : [];
|
|
191
210
|
const args = channel === "osascript" ? ["-e", DARWIN_SCRIPT, DARWIN_ARGV_MARKER, title, body] : channel === "terminal-notifier" ? ["-title", title, "-message", body, "-appName", "LaCode", "-priority", normalizeUrgency(request.urgency)] : (
|
|
192
|
-
//
|
|
193
|
-
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}
|
|
211
|
+
// Never auto-dismissed: the notification waits for the user to close it.
|
|
212
|
+
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`, NEVER_EXPIRE_FLAG, ...actionArgs]
|
|
194
213
|
);
|
|
195
|
-
const result = run(CHANNEL_BINARY[channel], args, 5e3);
|
|
214
|
+
const result = run(CHANNEL_BINARY[channel], args, actionable ? ACTION_WAIT_MS : 5e3);
|
|
196
215
|
playSound();
|
|
197
|
-
|
|
216
|
+
const action = actionable ? result.stdout.trim() : "";
|
|
217
|
+
return {
|
|
218
|
+
ok: result.ok,
|
|
219
|
+
detail: result.ok ? void 0 : result.stderr || result.detail,
|
|
220
|
+
transport: "local",
|
|
221
|
+
...action ? { action } : {}
|
|
222
|
+
};
|
|
198
223
|
}
|
|
199
224
|
function sendRemote(request, tunnel) {
|
|
200
225
|
if (!tunnel.user) return { ok: false, detail: "OC_LOCAL_USER is unset; the desktop user for the tunnel is unknown", transport: "remote" };
|
|
201
226
|
const connect = ["-o", `ConnectTimeout=${Math.max(1, Math.round(tunnel.connectTimeoutMs / 1e3))}`];
|
|
227
|
+
const actionable = safeRequestId(request.requestID) !== void 0;
|
|
228
|
+
const timeoutMs = actionable ? Math.max(tunnel.connectTimeoutMs + 2e3, ACTION_WAIT_MS + 2e3) : tunnel.connectTimeoutMs + 2e3;
|
|
229
|
+
const deliver = (args) => {
|
|
230
|
+
const result = run("ssh", args, timeoutMs);
|
|
231
|
+
const action = actionable ? result.stdout.trim() : "";
|
|
232
|
+
return {
|
|
233
|
+
ok: result.ok,
|
|
234
|
+
detail: result.ok ? void 0 : result.stderr || result.detail,
|
|
235
|
+
transport: "remote",
|
|
236
|
+
...action ? { action } : {}
|
|
237
|
+
};
|
|
238
|
+
};
|
|
202
239
|
if (tunnel.host) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
240
|
+
return deliver([
|
|
241
|
+
...SSH_OPTS,
|
|
242
|
+
...CONTROL_OPTS,
|
|
243
|
+
...connect,
|
|
244
|
+
"-i",
|
|
245
|
+
tunnel.key,
|
|
246
|
+
`${tunnel.user}@${tunnel.host}`,
|
|
247
|
+
"--",
|
|
248
|
+
encodePayload(request)
|
|
249
|
+
]);
|
|
209
250
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
251
|
+
return deliver([
|
|
252
|
+
...SSH_OPTS,
|
|
253
|
+
...connect,
|
|
254
|
+
"-i",
|
|
255
|
+
tunnel.key,
|
|
256
|
+
"-p",
|
|
257
|
+
String(tunnel.port),
|
|
258
|
+
`${tunnel.user}@localhost`,
|
|
259
|
+
"--",
|
|
260
|
+
encodePayload(request)
|
|
261
|
+
]);
|
|
216
262
|
}
|
|
217
263
|
function notify(request, env = process.env, platform = process.platform) {
|
|
218
264
|
try {
|
|
219
|
-
|
|
265
|
+
const result = isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
|
|
266
|
+
return result.action ? { ...result, action: result.action } : result;
|
|
220
267
|
} catch (error) {
|
|
221
268
|
return { ok: false, detail: String(error), transport: "none" };
|
|
222
269
|
}
|
|
@@ -249,6 +296,7 @@ function describe(env = process.env, platform = process.platform) {
|
|
|
249
296
|
export {
|
|
250
297
|
FIELD_SEPARATOR,
|
|
251
298
|
ICONS,
|
|
299
|
+
NOTIFICATION_ACTIONS,
|
|
252
300
|
SOUNDS,
|
|
253
301
|
describe,
|
|
254
302
|
encodePayload,
|
|
@@ -262,6 +310,7 @@ export {
|
|
|
262
310
|
parsePayload,
|
|
263
311
|
resolveTunnel,
|
|
264
312
|
safeMessage,
|
|
313
|
+
safeRequestId,
|
|
265
314
|
safeTitle,
|
|
266
315
|
sendLocal,
|
|
267
316
|
sendRemote,
|
package/docs/TUNNEL.md
CHANGED
|
@@ -15,8 +15,12 @@ gets it back.
|
|
|
15
15
|
│ ▼ │ ONE argument: │ forced command) │
|
|
16
16
|
│ lacode notify │ title\nmessage\n │ │ │
|
|
17
17
|
│ │ urgency\nicon\nsound │ ▼ │
|
|
18
|
-
│ │
|
|
18
|
+
│ │ \nrequestID │ notify-send / │
|
|
19
19
|
│ │ │ osascript │
|
|
20
|
+
│ ▲ │ ◄─────────────────────── │ │
|
|
21
|
+
│ │ the clicked button's │ │
|
|
22
|
+
│ │ name, on the forced │ │
|
|
23
|
+
│ │ command's stdout │ │
|
|
20
24
|
└──────────────────┘ └────────────────────────┘
|
|
21
25
|
```
|
|
22
26
|
|
|
@@ -27,27 +31,67 @@ listening port, no systemd unit and nothing to sync: the plugin owns the
|
|
|
27
31
|
transport, and it exists only while opencode runs — which is the only time a
|
|
28
32
|
notification matters.
|
|
29
33
|
|
|
30
|
-
**One argument, not
|
|
34
|
+
**One argument, not six.** ssh concatenates everything after the host into a
|
|
31
35
|
single command string, and a forced command starts with *no* positional
|
|
32
|
-
parameters. A sender that passes
|
|
33
|
-
string `"a b c d e"`; a receiver reading `$1..$
|
|
34
|
-
back to
|
|
35
|
-
as one newline-delimited argument:
|
|
36
|
+
parameters. A sender that passes six quoted arguments arrives as the single
|
|
37
|
+
string `"a b c d e f"`; a receiver reading `$1..$6` sees six empty strings,
|
|
38
|
+
falls back to six defaults, and pops up a blank notification. So the payload
|
|
39
|
+
crosses as one newline-delimited argument:
|
|
36
40
|
|
|
37
41
|
```
|
|
38
|
-
title\nmessage\nurgency\nicon\nsound
|
|
42
|
+
title\nmessage\nurgency\nicon\nsound\nrequestID
|
|
39
43
|
```
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
`done`, `question`, `permission` or `error`.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
Six fields — `title`, `message`, `urgency`, `icon`, `sound`, `requestID`.
|
|
46
|
+
`sound` is `none`, `done`, `question`, `permission` or `error`. The request id
|
|
47
|
+
is **last**, and it is opaque: it is never shown to you, never passed to a
|
|
48
|
+
command, and never used to build an argument — the only thing it decides is
|
|
49
|
+
whether the notification carries buttons. A sender that stops at `icon`
|
|
50
|
+
simply leaves the rest empty, and an empty or unrecognised name is `none`, so a
|
|
51
|
+
four-field payload is a valid one; a payload with fewer than six fields carries
|
|
52
|
+
no request id at all, and therefore no buttons, so an older sender still works
|
|
53
|
+
against a receiver that expects six.
|
|
45
54
|
|
|
46
55
|
Newline is safe as the delimiter because both ends strip control characters from
|
|
47
56
|
the free-text fields before assembling or reading it, and `urgency`, `icon` and
|
|
48
57
|
`sound` come from whitelists. No field can contain the delimiter, so a message
|
|
49
58
|
full of spaces, quotes or `*` survives intact.
|
|
50
59
|
|
|
60
|
+
## The way back
|
|
61
|
+
|
|
62
|
+
The hop was one-way until now: the notification went up to the desktop and
|
|
63
|
+
nothing came back, because there was nothing on the wire to carry an answer.
|
|
64
|
+
It is two-way now, and what made it two-way was not a new mechanism. **The
|
|
65
|
+
desktop end is an ssh forced command, so its stdout comes back down the same
|
|
66
|
+
ssh connection the notification went up.** That channel was already there and
|
|
67
|
+
nobody used it: a forced command's stdout is carried to whoever opened the
|
|
68
|
+
connection, so the answer rides home on the connection that is already open —
|
|
69
|
+
no second connection, no listener, no port, no new key, no HTTP server.
|
|
70
|
+
|
|
71
|
+
A permission notification that carries a request id is raised with three
|
|
72
|
+
buttons: **Allow once**, **Always allow** and **Deny**. `notify-send` waits
|
|
73
|
+
for the click and prints the chosen button's *name* on stdout — `once`,
|
|
74
|
+
`always` or `reject`, and "reject" is opencode's own word for deny, not ours.
|
|
75
|
+
The name needs no translation: it is already one of the values the server
|
|
76
|
+
accepts as a permission reply. The plugin reads it off the helper's stdout and
|
|
77
|
+
answers the permission through opencode's own reply route.
|
|
78
|
+
|
|
79
|
+
Two things follow from the shape. An **empty** stdout means the popup was
|
|
80
|
+
dismissed without a button being chosen: nothing is answered, and the
|
|
81
|
+
permission is left exactly as it was. And a permission that was **already
|
|
82
|
+
answered** — most often in the terminal — comes back as "not found" rather
|
|
83
|
+
than as a failure, because the user did what they had to do and clicking a
|
|
84
|
+
stale notification is not an error.
|
|
85
|
+
|
|
86
|
+
The wait is bounded: ten minutes on the desktop side, and the hop's own
|
|
87
|
+
deadline is stretched to outlast it, because an answer cut off by a timeout is
|
|
88
|
+
worse than no answer at all.
|
|
89
|
+
|
|
90
|
+
The buttons are **Linux-only**. `notify-send --action` is the only one of the
|
|
91
|
+
three channels that can offer one — `osascript` and `terminal-notifier` have no
|
|
92
|
+
action support — so a macOS desktop gets a plain notice, and the answer there
|
|
93
|
+
still has to be given in the terminal.
|
|
94
|
+
|
|
51
95
|
## Setting it up
|
|
52
96
|
|
|
53
97
|
Once, on the **remote host** (where opencode runs):
|
|
@@ -95,11 +139,20 @@ SOUND is optional and last: `none`, `done`, `question`, `permission` or `error`,
|
|
|
95
139
|
one bundled tone per outcome. Leave it off and nothing plays; the line above
|
|
96
140
|
without a fifth word is correct.
|
|
97
141
|
|
|
98
|
-
`
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
142
|
+
`LACODE_REQUEST_ID` in the environment is the one thing that makes a send
|
|
143
|
+
actionable: it is the permission request id, it is opaque, and it is set on the
|
|
144
|
+
helper by the plugin rather than by you. A send with it carries the three
|
|
145
|
+
buttons on Linux and prints the name of the clicked one on stdout; a send
|
|
146
|
+
without it is an ordinary notice that prints nothing.
|
|
147
|
+
|
|
148
|
+
A plain `notify` is fire-and-forget by design. An unreachable desktop, a
|
|
149
|
+
missing key, no notifier installed at all — all of them print a warning on
|
|
150
|
+
stderr and exit 0. A notification must never be able to fail the process that
|
|
151
|
+
tried to send one; an agent must not go down because a laptop went to sleep.
|
|
152
|
+
`forward` is the exception: it exits with the remote's status, because its
|
|
153
|
+
caller wants to know. An **actionable** notify is the other exception, and it
|
|
154
|
+
is bounded rather than open-ended: it waits for the click, because the answer
|
|
155
|
+
is the whole point of it, and it still exits 0 whatever happens.
|
|
103
156
|
|
|
104
157
|
On macOS the text is passed to `osascript` as arguments to an `on run argv`
|
|
105
158
|
handler, never spliced into the script source — a session title is not trusted
|
|
@@ -115,6 +168,14 @@ delivers locally through the platform channel — `notify-send` on Linux,
|
|
|
115
168
|
channel as a local send, **the desktop half is not Linux-only**: a Mac desktop
|
|
116
169
|
receives notifications too.
|
|
117
170
|
|
|
171
|
+
The buttons are the exception to that. Only `notify-send` can offer one, so a
|
|
172
|
+
payload with a request id is an actionable notification on Linux and a plain
|
|
173
|
+
notice — one that prints nothing — on macOS. What the receiver prints on stdout
|
|
174
|
+
is the chosen button's name, and that stdout is what carries the answer back up
|
|
175
|
+
the connection (see [The way back](#the-way-back)). The request id itself is
|
|
176
|
+
never passed to a command and never printed — only its emptiness is ever
|
|
177
|
+
tested.
|
|
178
|
+
|
|
118
179
|
It never forwards: `SSH_CLIENT` is set for a forced command as well, so it calls
|
|
119
180
|
the local channel directly rather than `notify()` (which would loop back).
|
|
120
181
|
|
|
@@ -152,6 +213,11 @@ documented with the other plugin options in the README. Set it to `0`, `false`,
|
|
|
152
213
|
`off` or `no` to silence notifications; anything else — a typo included — leaves
|
|
153
214
|
sound on, because a misspelt variable must not be how you turn a feature off.
|
|
154
215
|
|
|
216
|
+
`LACODE_REQUEST_ID` is absent for the same kind of reason, and it is not yours
|
|
217
|
+
to set either: the plugin puts the permission request id on the helper
|
|
218
|
+
process, and the helper carries it as the payload's sixth field. The hop only
|
|
219
|
+
ever asks whether it is there.
|
|
220
|
+
|
|
155
221
|
## The reverse forward
|
|
156
222
|
|
|
157
223
|
A host that sets `OC_TUNNEL_PORT` and leaves `OC_LOCAL_HOST` unset reaches the
|
|
@@ -161,3 +227,7 @@ desktop through a reverse forward: `sendRemote` falls back to an ssh call to
|
|
|
161
227
|
(`bin/oc-notify-receiver`). That receiver is the Linux end of the hop: it
|
|
162
228
|
delivers through `notify-send`, and libnotify is a Linux thing, so a Mac cannot
|
|
163
229
|
be this end of the tunnel.
|
|
230
|
+
|
|
231
|
+
The way back works the same way here: the receiver's stdout comes back down
|
|
232
|
+
the forward, on the connection the notification went up, so a click on one of
|
|
233
|
+
the three buttons is carried home the same way it is on the direct hop.
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -193,7 +193,7 @@ does not.
|
|
|
193
193
|
|
|
194
194
|
**Story ID(s) for tests:** US-11
|
|
195
195
|
**Acceptance criteria:**
|
|
196
|
-
- [ ] Given a session went busy and is now idle, When the idle event fires, Then a `done` notification is delivered.
|
|
196
|
+
- [ ] Given a session went busy and is now idle, When the idle event fires, Then a `done` notification is delivered and stays on the desktop until the user closes it.
|
|
197
197
|
- [ ] Given a session ends with an unrecognized error, When the error event fires, Then an `error` notification is delivered, it bypasses the duplicate-suppression cooldown, and it carries the error's own message.
|
|
198
198
|
- [ ] Given a recognized rate-limit or quota error, or a user abort, When a session error event fires, Then no failure notification is raised — the pool already handles the former and the user already knows the latter.
|
|
199
199
|
- [ ] Given a session that never went busy, When it goes idle, Then no notification is sent.
|
|
@@ -211,7 +211,7 @@ does not.
|
|
|
211
211
|
- [ ] Given a question and a completion occur inside the same cooldown window, When both are evaluated, Then they are not deduplicated against each other.
|
|
212
212
|
|
|
213
213
|
### US-25: Notification when opencode asks for permission
|
|
214
|
-
**As a** user, **I want** to be notified when opencode is blocked asking me for permission — including permission to reach a directory outside the worktree — **so that** a silent permission prompt is never mistaken for a hung agent.
|
|
214
|
+
**As a** user, **I want** to be notified when opencode is blocked asking me for permission — including permission to reach a directory outside the worktree — **so that** a silent permission prompt is never mistaken for a hung agent, and so that I can allow or deny it where the notification reaches me instead of going back to the terminal.
|
|
215
215
|
|
|
216
216
|
**Story ID(s) for tests:** US-25
|
|
217
217
|
**Acceptance criteria:**
|
|
@@ -219,8 +219,14 @@ does not.
|
|
|
219
219
|
- [ ] Given opencode asks to reach a directory outside the worktree, When the prompt fires, Then the notification names that directory, resolved from `metadata.parentDir`/`metadata.filepath` and with the trailing glob trimmed, because a glob is not something the user can judge.
|
|
220
220
|
- [ ] Given the permission is granted, denied, or dismissed, When `permission.replied` fires, Then no notification is sent — the prompt was the thing worth interrupting for, not the answer — and only the parked-session block is cleared.
|
|
221
221
|
- [ ] Given a session is parked on a permission prompt, When the idle event fires, Then no "finished" notification is sent, because nothing finished.
|
|
222
|
-
- [ ] Given the plugin observes a permission prompt, When it handles the event, Then it
|
|
222
|
+
- [ ] Given the plugin observes a permission prompt, When it handles the event, Then it registers no `permission.ask` hook and mutates nothing in the payload — that hook's `output.status` is the permission decision itself, and deciding a prompt inside a handler is not the same as answering the one the user was asked for. A permission is answered through the server's own reply route, and only when the user says which answer they meant.
|
|
223
223
|
- [ ] Given a `permission` notification is raised, When it is evaluated against the cooldown, Then it bypasses it, since an unannounced block is the failure this story exists to remove.
|
|
224
|
+
- [ ] Given a permission prompt on Linux, When the notification is delivered, Then it is exactly one notification carrying three buttons — **Allow once**, **Always allow** and **Deny** — and the click raises no second notification.
|
|
225
|
+
- [ ] Given one of those buttons is clicked, When the answer comes back over the hop the notification came up, Then the permission is answered with that button's meaning through the server's reply route; the button names are opencode's own reply values (`once`, `always` and `reject` — the server's word for deny), so a click needs no translation to become an answer.
|
|
226
|
+
- [ ] Given the popup is dismissed without a button being chosen, When the notification returns, Then nothing is answered and the permission is left exactly as it was.
|
|
227
|
+
- [ ] Given the permission was already answered in the terminal, When a button is clicked, Then the reply is a no-op and is not reported as a failure.
|
|
228
|
+
- [ ] Given the desktop is a macOS one, When the same prompt is delivered, Then it is a plain notice with no buttons, because `osascript` and `terminal-notifier` have no action support and the answer still has to be given in the terminal there.
|
|
229
|
+
- [ ] Given a question is asked, When its notification is delivered, Then it carries no buttons and answers nothing, because a question is not a permission: the v1 client this plugin has has no question API at all, and the v2 routes need a bound socket the default TUI does not have.
|
|
224
230
|
|
|
225
231
|
### US-29: Notification sounds distinguish outcomes and can be switched off
|
|
226
232
|
**As a** user running many parallel sessions, **I want** each notification to arrive with its own sound, **so that** I can tell a finished session from a question or a permission prompt without looking at the popup.
|
package/package.json
CHANGED