@longleash/cli 0.1.0-rc.1 → 0.1.0-rc.11
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 +6 -3
- package/bin/longleash.mjs +1940 -53
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/runtime/app/dist/assets/app-Dv_E_rRc.js +27 -0
- package/runtime/app/dist/assets/styles-Dr4-130w.css +1 -0
- package/runtime/app/dist/assets/{styles-C-1gzhnT.js → styles-ej0i2z2o.js} +3 -3
- package/runtime/app/dist/assets/welcome-CbADUhH1.js +40 -0
- package/runtime/app/dist/build.json +1 -1
- package/runtime/app/dist/index.html +3 -3
- package/runtime/app/dist/sw.js +6 -1
- package/runtime/app/dist/welcome.html +3 -3
- package/runtime/daemon/bin/longleashd.mjs +737 -102
- package/runtime/app/dist/assets/app-CxamvvoS.js +0 -27
- package/runtime/app/dist/assets/styles-CkAMG7sK.css +0 -1
- package/runtime/app/dist/assets/welcome-CG_rEa--.js +0 -27
|
@@ -7,8 +7,8 @@ var __export = (target, all) => {
|
|
|
7
7
|
|
|
8
8
|
// ../daemon/bin/longleashd.ts
|
|
9
9
|
import { existsSync as existsSync7 } from "node:fs";
|
|
10
|
-
import { homedir as
|
|
11
|
-
import { dirname as dirname4, join as
|
|
10
|
+
import { homedir as homedir5 } from "node:os";
|
|
11
|
+
import { dirname as dirname4, join as join10, resolve as resolve6 } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
|
|
14
14
|
// ../daemon/src/daemon.ts
|
|
@@ -44,8 +44,8 @@ async function pauseCurrentSessionOwner(managed, external, session, actor, reaso
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// ../daemon/src/daemon.ts
|
|
47
|
-
import { homedir as
|
|
48
|
-
import { delimiter, join as
|
|
47
|
+
import { homedir as homedir4 } from "node:os";
|
|
48
|
+
import { delimiter, join as join7, resolve as resolve5 } from "node:path";
|
|
49
49
|
|
|
50
50
|
// ../daemon/src/eventlog.ts
|
|
51
51
|
import Database from "better-sqlite3";
|
|
@@ -5432,7 +5432,10 @@ var SessionOrigin = external_exports.enum(["phone", "daemon", "terminal", "vscod
|
|
|
5432
5432
|
var WorkspaceMode = external_exports.enum(["auto", "shared", "isolated"]);
|
|
5433
5433
|
var AgentEffort = external_exports.enum(["low", "medium", "high", "xhigh", "max"]);
|
|
5434
5434
|
var ThinkingMode = external_exports.enum(["adaptive", "disabled", "fixed"]);
|
|
5435
|
+
var SessionMode = external_exports.enum(["manual", "auto", "plan"]);
|
|
5435
5436
|
var SessionSettings = external_exports.object({
|
|
5437
|
+
/** Omitted means LongLeash's conservative manual mode. */
|
|
5438
|
+
mode: SessionMode.optional(),
|
|
5436
5439
|
/** Omitted means the provider's current default; model ids never enter a shell command. */
|
|
5437
5440
|
model: external_exports.string().trim().min(1).max(120).optional(),
|
|
5438
5441
|
effort: AgentEffort.optional(),
|
|
@@ -5493,6 +5496,10 @@ var sessionStartedPayload = external_exports.object({
|
|
|
5493
5496
|
origin: SessionOrigin.optional(),
|
|
5494
5497
|
/** Who owns the process now; unlike origin, this changes after a safe handoff. */
|
|
5495
5498
|
controller: external_exports.enum(["longleash", "external"]).optional(),
|
|
5499
|
+
/** Where the process is controlled now; origin remains its historical birthplace. */
|
|
5500
|
+
surface: SessionOrigin.optional(),
|
|
5501
|
+
/** Observation-only means the native surface is visible but has not exposed process control. */
|
|
5502
|
+
control: external_exports.enum(["full", "observe"]).optional(),
|
|
5496
5503
|
/**
|
|
5497
5504
|
* The agent's own conversation id. Carried to the phone so a person can pick the
|
|
5498
5505
|
* SAME conversation back up at their keyboard — `claude --resume <id>` in the
|
|
@@ -5526,6 +5533,10 @@ var sessionStatusPayload = external_exports.object({
|
|
|
5526
5533
|
/** LongLeash's own gate for this session: whether it should page the phone at all. */
|
|
5527
5534
|
gate: SessionGate.optional(),
|
|
5528
5535
|
controller: external_exports.enum(["longleash", "external"]).optional(),
|
|
5536
|
+
/** Current UI/process surface after a handoff. */
|
|
5537
|
+
surface: SessionOrigin.optional(),
|
|
5538
|
+
/** Observation-only sessions are never presented as remotely stoppable or writable. */
|
|
5539
|
+
control: external_exports.enum(["full", "observe"]).optional(),
|
|
5529
5540
|
/** Current overrides. An empty object explicitly means provider defaults. */
|
|
5530
5541
|
settings: SessionSettings.optional(),
|
|
5531
5542
|
/** A second writer was observed in this checkout; text accompanies color for accessibility. */
|
|
@@ -5540,6 +5551,13 @@ var streamDeltaPayload = external_exports.object({
|
|
|
5540
5551
|
kind: external_exports.enum(["text", "tool", "thinking", "user"]),
|
|
5541
5552
|
text: external_exports.string()
|
|
5542
5553
|
}).passthrough();
|
|
5554
|
+
var transcriptResetPayload = external_exports.object({
|
|
5555
|
+
reason: external_exports.enum(["provider-snapshot", "recovery"]),
|
|
5556
|
+
blocks: external_exports.array(external_exports.object({
|
|
5557
|
+
kind: external_exports.enum(["text", "tool", "thinking", "user"]),
|
|
5558
|
+
text: external_exports.string().max(12e4)
|
|
5559
|
+
}).strict()).max(80).optional()
|
|
5560
|
+
}).passthrough();
|
|
5543
5561
|
var QuestionOption = external_exports.object({
|
|
5544
5562
|
label: external_exports.string().min(1),
|
|
5545
5563
|
description: external_exports.string().default(""),
|
|
@@ -5599,6 +5617,7 @@ function event(type, payload) {
|
|
|
5599
5617
|
var SessionEventSchema = external_exports.discriminatedUnion("type", [
|
|
5600
5618
|
event("session.started", sessionStartedPayload),
|
|
5601
5619
|
event("session.status", sessionStatusPayload),
|
|
5620
|
+
event("session.transcript.reset", transcriptResetPayload),
|
|
5602
5621
|
event("stream.delta", streamDeltaPayload),
|
|
5603
5622
|
event("approval.requested", approvalRequestedPayload),
|
|
5604
5623
|
event("approval.decided", approvalDecidedPayload),
|
|
@@ -5614,7 +5633,9 @@ var subscribeMessage = external_exports.object({
|
|
|
5614
5633
|
...clientBase,
|
|
5615
5634
|
type: external_exports.literal("subscribe"),
|
|
5616
5635
|
sessionId: external_exports.string().min(1),
|
|
5617
|
-
fromCursor: external_exports.number().int().nonnegative()
|
|
5636
|
+
fromCursor: external_exports.number().int().nonnegative(),
|
|
5637
|
+
/** Optional replay barrier. New clients use the echoed id to paint catch-up only once. */
|
|
5638
|
+
syncId: external_exports.string().min(1).max(120).optional()
|
|
5618
5639
|
}).passthrough();
|
|
5619
5640
|
var decisionMessage = external_exports.object({
|
|
5620
5641
|
...clientBase,
|
|
@@ -5700,6 +5721,20 @@ var takeOverMessage = external_exports.object({
|
|
|
5700
5721
|
sessionId: external_exports.string().min(1),
|
|
5701
5722
|
text: external_exports.string().min(1)
|
|
5702
5723
|
}).passthrough();
|
|
5724
|
+
var reclaimSessionMessage = external_exports.object({
|
|
5725
|
+
...clientBase,
|
|
5726
|
+
type: external_exports.literal("reclaimSession"),
|
|
5727
|
+
sessionId: external_exports.string().min(1)
|
|
5728
|
+
}).passthrough();
|
|
5729
|
+
var renameSessionMessage = external_exports.object({
|
|
5730
|
+
...clientBase,
|
|
5731
|
+
type: external_exports.literal("renameSession"),
|
|
5732
|
+
sessionId: external_exports.string().min(1),
|
|
5733
|
+
title: external_exports.string().trim().min(1).max(80).refine(
|
|
5734
|
+
(value) => value.replace(/[\u0000-\u001f\u007f]/g, "").trim().length > 0,
|
|
5735
|
+
"A session name needs visible text."
|
|
5736
|
+
)
|
|
5737
|
+
}).passthrough();
|
|
5703
5738
|
var previewDelegationMessage = external_exports.object({
|
|
5704
5739
|
...clientBase,
|
|
5705
5740
|
type: external_exports.literal("previewDelegation"),
|
|
@@ -5842,6 +5877,8 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
|
|
|
5842
5877
|
pushUnsubscribeMessage,
|
|
5843
5878
|
pushTestMessage,
|
|
5844
5879
|
takeOverMessage,
|
|
5880
|
+
reclaimSessionMessage,
|
|
5881
|
+
renameSessionMessage,
|
|
5845
5882
|
setGateMessage,
|
|
5846
5883
|
previewDelegationMessage,
|
|
5847
5884
|
startDelegationMessage,
|
|
@@ -5859,6 +5896,7 @@ var EventLog = class {
|
|
|
5859
5896
|
insertStmt;
|
|
5860
5897
|
maxSeqStmt;
|
|
5861
5898
|
minSeqStmt;
|
|
5899
|
+
latestTsStmt;
|
|
5862
5900
|
selectFromStmt;
|
|
5863
5901
|
pruneStmt;
|
|
5864
5902
|
constructor(path, opts = {}) {
|
|
@@ -5874,7 +5912,12 @@ var EventLog = class {
|
|
|
5874
5912
|
type TEXT NOT NULL,
|
|
5875
5913
|
payload TEXT NOT NULL,
|
|
5876
5914
|
PRIMARY KEY (session_id, seq)
|
|
5877
|
-
)
|
|
5915
|
+
);
|
|
5916
|
+
CREATE TABLE IF NOT EXISTS session_aliases (
|
|
5917
|
+
session_id TEXT PRIMARY KEY,
|
|
5918
|
+
title TEXT NOT NULL,
|
|
5919
|
+
updated_at INTEGER NOT NULL
|
|
5920
|
+
);
|
|
5878
5921
|
`);
|
|
5879
5922
|
this.now = opts.now ?? Date.now;
|
|
5880
5923
|
this.insertStmt = this.rawDb.prepare(
|
|
@@ -5886,6 +5929,9 @@ var EventLog = class {
|
|
|
5886
5929
|
this.minSeqStmt = this.rawDb.prepare(
|
|
5887
5930
|
"SELECT COALESCE(MIN(seq), 0) AS seq FROM events WHERE session_id = ?"
|
|
5888
5931
|
);
|
|
5932
|
+
this.latestTsStmt = this.rawDb.prepare(
|
|
5933
|
+
"SELECT COALESCE(MAX(ts), 0) AS ts FROM events WHERE session_id = ?"
|
|
5934
|
+
);
|
|
5889
5935
|
this.selectFromStmt = this.rawDb.prepare(
|
|
5890
5936
|
"SELECT * FROM events WHERE session_id = ? AND seq > ? ORDER BY seq ASC"
|
|
5891
5937
|
);
|
|
@@ -5940,6 +5986,19 @@ var EventLog = class {
|
|
|
5940
5986
|
latestSeq(sessionId) {
|
|
5941
5987
|
return this.maxSeqStmt.get(sessionId).seq;
|
|
5942
5988
|
}
|
|
5989
|
+
latestTimestamp(sessionId) {
|
|
5990
|
+
return this.latestTsStmt.get(sessionId).ts;
|
|
5991
|
+
}
|
|
5992
|
+
aliasFor(sessionId) {
|
|
5993
|
+
const row = this.rawDb.prepare("SELECT title FROM session_aliases WHERE session_id = ?").get(sessionId);
|
|
5994
|
+
return row?.title;
|
|
5995
|
+
}
|
|
5996
|
+
setAlias(sessionId, title) {
|
|
5997
|
+
this.rawDb.prepare(
|
|
5998
|
+
`INSERT INTO session_aliases (session_id, title, updated_at) VALUES (?, ?, ?)
|
|
5999
|
+
ON CONFLICT(session_id) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at`
|
|
6000
|
+
).run(sessionId, title, this.now());
|
|
6001
|
+
}
|
|
5943
6002
|
pruneBefore(sessionId, uptoExclusive) {
|
|
5944
6003
|
this.pruneStmt.run(sessionId, uptoExclusive);
|
|
5945
6004
|
}
|
|
@@ -6668,13 +6727,13 @@ function workspaceFromRow(row) {
|
|
|
6668
6727
|
}
|
|
6669
6728
|
var newId = (prefix) => `${prefix}_${randomBytes2(9).toString("base64url")}`;
|
|
6670
6729
|
function settlesWithin(promise, timeoutMs) {
|
|
6671
|
-
return new Promise((
|
|
6730
|
+
return new Promise((resolve7) => {
|
|
6672
6731
|
let settled = false;
|
|
6673
6732
|
const finish = (value) => {
|
|
6674
6733
|
if (settled) return;
|
|
6675
6734
|
settled = true;
|
|
6676
6735
|
clearTimeout(timer);
|
|
6677
|
-
|
|
6736
|
+
resolve7(value);
|
|
6678
6737
|
};
|
|
6679
6738
|
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
6680
6739
|
timer.unref?.();
|
|
@@ -7093,6 +7152,26 @@ var SessionManager = class {
|
|
|
7093
7152
|
};
|
|
7094
7153
|
});
|
|
7095
7154
|
}
|
|
7155
|
+
/** Find the stable LongLeash card that already owns this provider conversation id. */
|
|
7156
|
+
sessionIdForAgentSession(agentSessionId) {
|
|
7157
|
+
const row = this.approvals.rawDb.prepare("SELECT session_id FROM sessions WHERE agent_session_id = ? ORDER BY started_at DESC LIMIT 1").get(agentSessionId);
|
|
7158
|
+
return row?.session_id;
|
|
7159
|
+
}
|
|
7160
|
+
/** Persist a human label independently of whatever title a provider derives later. */
|
|
7161
|
+
renameSession(sessionId, title, emit = true) {
|
|
7162
|
+
const changed = this.approvals.rawDb.prepare("UPDATE sessions SET title = ? WHERE session_id = ?").run(title, sessionId).changes;
|
|
7163
|
+
if (changed === 0) return false;
|
|
7164
|
+
const live = this.sessions.get(sessionId);
|
|
7165
|
+
if (live !== void 0) live.title = title;
|
|
7166
|
+
this.eventLog.setAlias(sessionId, title);
|
|
7167
|
+
if (emit) {
|
|
7168
|
+
this.emit(sessionId, {
|
|
7169
|
+
type: "session.status",
|
|
7170
|
+
payload: { status: live?.status ?? "waiting", live: live !== void 0, title }
|
|
7171
|
+
});
|
|
7172
|
+
}
|
|
7173
|
+
return true;
|
|
7174
|
+
}
|
|
7096
7175
|
/**
|
|
7097
7176
|
* Adopt a finished TERMINAL session so a phone reply can wake it: the
|
|
7098
7177
|
* conversation lives in Claude Code's own storage, keyed by its resume id,
|
|
@@ -7609,7 +7688,7 @@ var SessionManager = class {
|
|
|
7609
7688
|
this.approvals.rawDb.prepare("DELETE FROM session_deliveries WHERE delivery_id = ?").run(deliveryId);
|
|
7610
7689
|
}
|
|
7611
7690
|
releasePending(session, message) {
|
|
7612
|
-
for (const [approvalId,
|
|
7691
|
+
for (const [approvalId, resolve7] of session.waiting) {
|
|
7613
7692
|
const decided = this.approvals.decide(approvalId, "denied", "system:session-ended", message);
|
|
7614
7693
|
if (decided) {
|
|
7615
7694
|
this.emit(session.sessionId, {
|
|
@@ -7622,7 +7701,7 @@ var SessionManager = class {
|
|
|
7622
7701
|
}
|
|
7623
7702
|
});
|
|
7624
7703
|
}
|
|
7625
|
-
|
|
7704
|
+
resolve7({ behavior: "deny", message });
|
|
7626
7705
|
}
|
|
7627
7706
|
session.waiting.clear();
|
|
7628
7707
|
}
|
|
@@ -7643,9 +7722,9 @@ var SessionManager = class {
|
|
|
7643
7722
|
waitForApproval(sessionId) {
|
|
7644
7723
|
const alreadyPending = this.approvals.listPending().some((a) => a.sessionId === sessionId);
|
|
7645
7724
|
if (alreadyPending) return Promise.resolve();
|
|
7646
|
-
return new Promise((
|
|
7725
|
+
return new Promise((resolve7) => {
|
|
7647
7726
|
const waiters = this.approvalWaiters.get(sessionId) ?? [];
|
|
7648
|
-
waiters.push(
|
|
7727
|
+
waiters.push(resolve7);
|
|
7649
7728
|
this.approvalWaiters.set(sessionId, waiters);
|
|
7650
7729
|
});
|
|
7651
7730
|
}
|
|
@@ -7694,8 +7773,8 @@ var SessionManager = class {
|
|
|
7694
7773
|
outsideRoot
|
|
7695
7774
|
}
|
|
7696
7775
|
});
|
|
7697
|
-
const decision = new Promise((
|
|
7698
|
-
waiting.set(approvalId,
|
|
7776
|
+
const decision = new Promise((resolve7) => {
|
|
7777
|
+
waiting.set(approvalId, resolve7);
|
|
7699
7778
|
});
|
|
7700
7779
|
for (const waiter of this.approvalWaiters.get(sessionId) ?? []) waiter();
|
|
7701
7780
|
this.approvalWaiters.delete(sessionId);
|
|
@@ -7864,6 +7943,7 @@ var ExternalSessions = class {
|
|
|
7864
7943
|
stopTimeoutMs;
|
|
7865
7944
|
onEnded;
|
|
7866
7945
|
workspace;
|
|
7946
|
+
resolveSessionId;
|
|
7867
7947
|
unsubscribeWorkspace;
|
|
7868
7948
|
constructor(opts) {
|
|
7869
7949
|
this.eventLog = opts.eventLog;
|
|
@@ -7883,6 +7963,7 @@ var ExternalSessions = class {
|
|
|
7883
7963
|
this.waitForExit = opts.waitForExit ?? ((pid, timeoutMs) => this.pollForExit(pid, timeoutMs));
|
|
7884
7964
|
this.onEnded = opts.onEnded;
|
|
7885
7965
|
this.workspace = opts.workspace;
|
|
7966
|
+
this.resolveSessionId = opts.resolveSessionId;
|
|
7886
7967
|
this.unsubscribeWorkspace = this.workspace?.subscribe(() => this.reconcileWorkspace());
|
|
7887
7968
|
for (const orphan of this.approvals.closeOrphans(
|
|
7888
7969
|
"The daemon restarted; this was answered in the terminal."
|
|
@@ -7906,6 +7987,38 @@ var ExternalSessions = class {
|
|
|
7906
7987
|
hasLiveSession(sessionId) {
|
|
7907
7988
|
return [...this.sessions.values()].some((session) => session.sessionId === sessionId);
|
|
7908
7989
|
}
|
|
7990
|
+
renameSession(sessionId, title) {
|
|
7991
|
+
const entry = [...this.sessions.entries()].find(([, session2]) => session2.sessionId === sessionId);
|
|
7992
|
+
if (entry === void 0) return false;
|
|
7993
|
+
const [agentSessionId, session] = entry;
|
|
7994
|
+
session.title = title;
|
|
7995
|
+
session.named = true;
|
|
7996
|
+
this.eventLog.setAlias(sessionId, title);
|
|
7997
|
+
this.persist(agentSessionId, session);
|
|
7998
|
+
this.emit(sessionId, {
|
|
7999
|
+
type: "session.status",
|
|
8000
|
+
payload: {
|
|
8001
|
+
status: session.status,
|
|
8002
|
+
live: true,
|
|
8003
|
+
title,
|
|
8004
|
+
controller: "external",
|
|
8005
|
+
control: session.observedOnly ? "observe" : "full",
|
|
8006
|
+
surface: session.surface
|
|
8007
|
+
}
|
|
8008
|
+
});
|
|
8009
|
+
return true;
|
|
8010
|
+
}
|
|
8011
|
+
/**
|
|
8012
|
+
* End a native writer so the same conversation can be parked on the phone.
|
|
8013
|
+
* Observation-only sessions have no authenticated PID and are never remotely terminated.
|
|
8014
|
+
*/
|
|
8015
|
+
async reclaimToPhone(externalSessionId, decidedBy) {
|
|
8016
|
+
const session = [...this.sessions.values()].find((item) => item.sessionId === externalSessionId);
|
|
8017
|
+
if (session === void 0) return "not-found";
|
|
8018
|
+
if (session.observedOnly) return "observe-only";
|
|
8019
|
+
if (session.pid === null) return "unverified";
|
|
8020
|
+
return await this.stop(externalSessionId, decidedBy) ? "reclaimed" : "unverified";
|
|
8021
|
+
}
|
|
7909
8022
|
/**
|
|
7910
8023
|
* Take back the sessions a previous run was watching.
|
|
7911
8024
|
*
|
|
@@ -7934,7 +8047,7 @@ var ExternalSessions = class {
|
|
|
7934
8047
|
transcriptPath: row.transcriptPath,
|
|
7935
8048
|
status: "running",
|
|
7936
8049
|
startedAt: row.startedAt,
|
|
7937
|
-
title: row.title,
|
|
8050
|
+
title: this.eventLog.aliasFor(row.sessionId) ?? row.title,
|
|
7938
8051
|
pid: row.pid,
|
|
7939
8052
|
named: true,
|
|
7940
8053
|
permissionMode: null,
|
|
@@ -7943,7 +8056,8 @@ var ExternalSessions = class {
|
|
|
7943
8056
|
offset: existsSync(row.transcriptPath) ? statSync(row.transcriptPath).size : 0,
|
|
7944
8057
|
remainder: "",
|
|
7945
8058
|
timer: null,
|
|
7946
|
-
suspended: false
|
|
8059
|
+
suspended: false,
|
|
8060
|
+
observedOnly: false
|
|
7947
8061
|
};
|
|
7948
8062
|
this.sessions.set(row.agentSessionId, session);
|
|
7949
8063
|
this.claimWorkspace(session);
|
|
@@ -7951,7 +8065,13 @@ var ExternalSessions = class {
|
|
|
7951
8065
|
session.timer.unref?.();
|
|
7952
8066
|
this.emit(session.sessionId, {
|
|
7953
8067
|
type: "session.status",
|
|
7954
|
-
payload: {
|
|
8068
|
+
payload: {
|
|
8069
|
+
status: "running",
|
|
8070
|
+
live: true,
|
|
8071
|
+
controller: "external",
|
|
8072
|
+
control: "full",
|
|
8073
|
+
surface: session.surface
|
|
8074
|
+
}
|
|
7955
8075
|
});
|
|
7956
8076
|
}
|
|
7957
8077
|
}
|
|
@@ -7964,6 +8084,39 @@ var ExternalSessions = class {
|
|
|
7964
8084
|
}
|
|
7965
8085
|
this.claimWorkspace(session);
|
|
7966
8086
|
}
|
|
8087
|
+
/** Adopt a Codex transcript observed after startup, including sessions older than the hooks. */
|
|
8088
|
+
observeCodexSession(info) {
|
|
8089
|
+
const session = this.ensure(info.sessionId, info.cwd, info.transcriptPath, "codex", info.surface, true);
|
|
8090
|
+
const alias = this.eventLog.aliasFor(session.sessionId);
|
|
8091
|
+
const nextTitle = alias ?? info.title;
|
|
8092
|
+
if (nextTitle !== void 0 && nextTitle !== session.title) {
|
|
8093
|
+
session.named = true;
|
|
8094
|
+
session.title = nextTitle;
|
|
8095
|
+
this.persist(info.sessionId, session);
|
|
8096
|
+
}
|
|
8097
|
+
if (session.observedOnly && session.observedFingerprint === void 0) {
|
|
8098
|
+
session.observedFingerprint = JSON.stringify(info.snapshot);
|
|
8099
|
+
this.emit(session.sessionId, {
|
|
8100
|
+
type: "session.transcript.reset",
|
|
8101
|
+
payload: { reason: "provider-snapshot", blocks: info.snapshot }
|
|
8102
|
+
});
|
|
8103
|
+
}
|
|
8104
|
+
this.emit(session.sessionId, {
|
|
8105
|
+
type: "session.status",
|
|
8106
|
+
payload: {
|
|
8107
|
+
status: session.status,
|
|
8108
|
+
live: true,
|
|
8109
|
+
controller: "external",
|
|
8110
|
+
control: session.observedOnly ? "observe" : "full",
|
|
8111
|
+
...nextTitle === void 0 ? {} : { title: nextTitle }
|
|
8112
|
+
}
|
|
8113
|
+
});
|
|
8114
|
+
if (session.observedOnly) {
|
|
8115
|
+
if (session.workspaceConflict !== void 0) delete session.workspaceConflict;
|
|
8116
|
+
} else {
|
|
8117
|
+
this.claimWorkspace(session);
|
|
8118
|
+
}
|
|
8119
|
+
}
|
|
7967
8120
|
/**
|
|
7968
8121
|
* Stop a terminal session from the phone — for real. The recorded pid is only
|
|
7969
8122
|
* trusted after re-verifying it still belongs to a claude process, because PIDs
|
|
@@ -7973,6 +8126,7 @@ var ExternalSessions = class {
|
|
|
7973
8126
|
const entry = [...this.sessions.entries()].find(([, s]) => s.sessionId === externalSessionId);
|
|
7974
8127
|
if (!entry) return false;
|
|
7975
8128
|
const [claudeSessionId, session] = entry;
|
|
8129
|
+
if (session.observedOnly) return false;
|
|
7976
8130
|
if (session.pid === null || !this.isClaudeProcess(session.pid)) {
|
|
7977
8131
|
this.emit(session.sessionId, {
|
|
7978
8132
|
type: "stream.delta",
|
|
@@ -8055,7 +8209,7 @@ var ExternalSessions = class {
|
|
|
8055
8209
|
const deadline = Date.now() + timeoutMs;
|
|
8056
8210
|
while (Date.now() < deadline) {
|
|
8057
8211
|
if (!this.isClaudeProcess(pid)) return true;
|
|
8058
|
-
await new Promise((
|
|
8212
|
+
await new Promise((resolve7) => setTimeout(resolve7, 50));
|
|
8059
8213
|
}
|
|
8060
8214
|
return !this.isClaudeProcess(pid);
|
|
8061
8215
|
}
|
|
@@ -8063,7 +8217,7 @@ var ExternalSessions = class {
|
|
|
8063
8217
|
* A tool wants to run in the terminal. Returns what the hook should tell
|
|
8064
8218
|
* Claude Code — and "ask" always means "behave as if we were never here".
|
|
8065
8219
|
*/
|
|
8066
|
-
preToolUse(claudeSessionId, cwd, transcriptPath, toolName, toolInput,
|
|
8220
|
+
preToolUse(claudeSessionId, cwd, transcriptPath, toolName, toolInput, permissionMode2, opts = {}) {
|
|
8067
8221
|
const { dedupeKey, agent = "claude", surface = "terminal", abandoned, enforceWorkspace } = opts;
|
|
8068
8222
|
const session = this.ensure(claudeSessionId, cwd, transcriptPath, agent, surface);
|
|
8069
8223
|
if (enforceWorkspace === true && session.workspaceConflict !== void 0) {
|
|
@@ -8080,11 +8234,11 @@ var ExternalSessions = class {
|
|
|
8080
8234
|
if (session.gate === "auto") {
|
|
8081
8235
|
return Promise.resolve({ decision: "allow", reason: "Auto-approved: you muted this session." });
|
|
8082
8236
|
}
|
|
8083
|
-
if (
|
|
8084
|
-
session.permissionMode =
|
|
8237
|
+
if (permissionMode2 !== void 0 && permissionMode2 !== session.permissionMode) {
|
|
8238
|
+
session.permissionMode = permissionMode2;
|
|
8085
8239
|
this.emit(session.sessionId, {
|
|
8086
8240
|
type: "session.status",
|
|
8087
|
-
payload: { status: session.status, live: true, permissionMode }
|
|
8241
|
+
payload: { status: session.status, live: true, permissionMode: permissionMode2 }
|
|
8088
8242
|
});
|
|
8089
8243
|
}
|
|
8090
8244
|
const who = this.audience();
|
|
@@ -8119,7 +8273,7 @@ var ExternalSessions = class {
|
|
|
8119
8273
|
...questions === null ? {} : { questions }
|
|
8120
8274
|
}
|
|
8121
8275
|
});
|
|
8122
|
-
const pending = new Promise((
|
|
8276
|
+
const pending = new Promise((resolve7) => {
|
|
8123
8277
|
const timer = setTimeout(() => {
|
|
8124
8278
|
this.waiting.delete(approvalId);
|
|
8125
8279
|
this.approvals.decide(
|
|
@@ -8137,11 +8291,11 @@ var ExternalSessions = class {
|
|
|
8137
8291
|
reply: "Answered in the terminal instead."
|
|
8138
8292
|
}
|
|
8139
8293
|
});
|
|
8140
|
-
|
|
8294
|
+
resolve7({ decision: "ask", reason: "No answer from the phone; deciding in the terminal." });
|
|
8141
8295
|
}, holdMs);
|
|
8142
8296
|
timer.unref?.();
|
|
8143
8297
|
this.waiting.set(approvalId, {
|
|
8144
|
-
resolve:
|
|
8298
|
+
resolve: resolve7,
|
|
8145
8299
|
timer,
|
|
8146
8300
|
sessionId: session.sessionId,
|
|
8147
8301
|
...questions === null ? {} : { questions }
|
|
@@ -8258,7 +8412,7 @@ var ExternalSessions = class {
|
|
|
8258
8412
|
/** Mute or unmute a session from the phone. Returns false for a session it does not know. */
|
|
8259
8413
|
setGate(externalSessionId, gate) {
|
|
8260
8414
|
const session = [...this.sessions.values()].find((s) => s.sessionId === externalSessionId);
|
|
8261
|
-
if (!session) return false;
|
|
8415
|
+
if (!session || session.observedOnly) return false;
|
|
8262
8416
|
session.gate = gate;
|
|
8263
8417
|
this.emit(session.sessionId, {
|
|
8264
8418
|
type: "session.status",
|
|
@@ -8311,11 +8465,13 @@ var ExternalSessions = class {
|
|
|
8311
8465
|
resumeId: claudeSessionId,
|
|
8312
8466
|
gate: session.gate,
|
|
8313
8467
|
controller: "external",
|
|
8468
|
+
control: session.observedOnly ? "observe" : "full",
|
|
8314
8469
|
...session.workspaceConflict === void 0 ? {} : { workspaceConflict: session.workspaceConflict }
|
|
8315
8470
|
}));
|
|
8316
8471
|
}
|
|
8317
8472
|
/** Write this session down so a restart can take it back. */
|
|
8318
8473
|
persist(agentSessionId, session) {
|
|
8474
|
+
if (session.observedOnly) return;
|
|
8319
8475
|
this.registry?.remember({
|
|
8320
8476
|
agentSessionId,
|
|
8321
8477
|
sessionId: session.sessionId,
|
|
@@ -8367,12 +8523,34 @@ var ExternalSessions = class {
|
|
|
8367
8523
|
this.sessions.clear();
|
|
8368
8524
|
}
|
|
8369
8525
|
/* ------------------------------------------------------------------ internals */
|
|
8370
|
-
ensure(claudeSessionId, cwd, transcriptPath, agent = "claude", surface = "terminal") {
|
|
8526
|
+
ensure(claudeSessionId, cwd, transcriptPath, agent = "claude", surface = "terminal", tailOnly = false) {
|
|
8371
8527
|
const existing = this.sessions.get(claudeSessionId);
|
|
8372
|
-
if (existing)
|
|
8373
|
-
|
|
8528
|
+
if (existing) {
|
|
8529
|
+
if (!tailOnly && existing.observedOnly) {
|
|
8530
|
+
existing.observedOnly = false;
|
|
8531
|
+
this.persist(claudeSessionId, existing);
|
|
8532
|
+
this.emit(existing.sessionId, {
|
|
8533
|
+
type: "session.status",
|
|
8534
|
+
payload: {
|
|
8535
|
+
status: existing.status,
|
|
8536
|
+
live: true,
|
|
8537
|
+
controller: "external",
|
|
8538
|
+
control: "full",
|
|
8539
|
+
surface: existing.surface
|
|
8540
|
+
}
|
|
8541
|
+
});
|
|
8542
|
+
if (existing.timer === null) {
|
|
8543
|
+
existing.timer = setInterval(() => this.drain(existing), this.pollMs);
|
|
8544
|
+
existing.timer.unref?.();
|
|
8545
|
+
this.drain(existing);
|
|
8546
|
+
}
|
|
8547
|
+
}
|
|
8548
|
+
return existing;
|
|
8549
|
+
}
|
|
8550
|
+
const sessionId = this.resolveSessionId?.(claudeSessionId) ?? `ext_${claudeSessionId}`;
|
|
8374
8551
|
const replay = this.eventLog.replay(sessionId, 0);
|
|
8375
8552
|
const hasHistory = replay.gap === false ? replay.events.length > 0 : true;
|
|
8553
|
+
const alias = this.eventLog.aliasFor(sessionId);
|
|
8376
8554
|
const session = {
|
|
8377
8555
|
sessionId,
|
|
8378
8556
|
agent,
|
|
@@ -8381,15 +8559,16 @@ var ExternalSessions = class {
|
|
|
8381
8559
|
transcriptPath,
|
|
8382
8560
|
status: "running",
|
|
8383
8561
|
startedAt: this.now(),
|
|
8384
|
-
title: `${basename3(cwd)} \u2014 ${surface === "vscode" ? "VS Code" : AGENT_LABEL[agent]}`,
|
|
8562
|
+
title: alias ?? `${basename3(cwd)} \u2014 ${surface === "vscode" ? "VS Code" : AGENT_LABEL[agent]}`,
|
|
8385
8563
|
pid: null,
|
|
8386
|
-
named:
|
|
8564
|
+
named: alias !== void 0,
|
|
8387
8565
|
permissionMode: null,
|
|
8388
8566
|
gate: "ask",
|
|
8389
|
-
offset: hasHistory && existsSync(transcriptPath) ? statSync(transcriptPath).size : 0,
|
|
8567
|
+
offset: (tailOnly || hasHistory) && existsSync(transcriptPath) ? statSync(transcriptPath).size : 0,
|
|
8390
8568
|
remainder: "",
|
|
8391
8569
|
timer: null,
|
|
8392
|
-
suspended: false
|
|
8570
|
+
suspended: false,
|
|
8571
|
+
observedOnly: tailOnly
|
|
8393
8572
|
};
|
|
8394
8573
|
this.sessions.set(claudeSessionId, session);
|
|
8395
8574
|
this.persist(claudeSessionId, session);
|
|
@@ -8402,13 +8581,21 @@ var ExternalSessions = class {
|
|
|
8402
8581
|
title: session.title,
|
|
8403
8582
|
origin: surface,
|
|
8404
8583
|
controller: "external",
|
|
8584
|
+
control: tailOnly ? "observe" : "full",
|
|
8585
|
+
surface,
|
|
8405
8586
|
resumeId: claudeSessionId
|
|
8406
8587
|
}
|
|
8407
8588
|
});
|
|
8408
8589
|
}
|
|
8409
8590
|
this.emit(sessionId, {
|
|
8410
8591
|
type: "session.status",
|
|
8411
|
-
payload: {
|
|
8592
|
+
payload: {
|
|
8593
|
+
status: "running",
|
|
8594
|
+
live: true,
|
|
8595
|
+
controller: "external",
|
|
8596
|
+
control: tailOnly ? "observe" : "full",
|
|
8597
|
+
surface
|
|
8598
|
+
}
|
|
8412
8599
|
});
|
|
8413
8600
|
session.timer = setInterval(() => this.drain(session), this.pollMs);
|
|
8414
8601
|
session.timer.unref?.();
|
|
@@ -8417,7 +8604,9 @@ var ExternalSessions = class {
|
|
|
8417
8604
|
}
|
|
8418
8605
|
/** Retry claims after startup reconciliation released dead managed-session ownership. */
|
|
8419
8606
|
reconcileWorkspace() {
|
|
8420
|
-
for (const session of this.sessions.values())
|
|
8607
|
+
for (const session of this.sessions.values()) {
|
|
8608
|
+
if (!session.observedOnly) this.claimWorkspace(session);
|
|
8609
|
+
}
|
|
8421
8610
|
}
|
|
8422
8611
|
claimWorkspace(session) {
|
|
8423
8612
|
if (this.workspace === void 0 || session.cwd.trim() === "") return;
|
|
@@ -8636,6 +8825,9 @@ var RelayLink = class {
|
|
|
8636
8825
|
reconnectTimer = null;
|
|
8637
8826
|
keepaliveTimer = null;
|
|
8638
8827
|
keepaliveMs;
|
|
8828
|
+
/** WebCrypto promises may resolve out of order; preserve the server's event order explicitly. */
|
|
8829
|
+
outbound = Promise.resolve();
|
|
8830
|
+
inbound = Promise.resolve();
|
|
8639
8831
|
constructor(opts) {
|
|
8640
8832
|
this.opts = opts;
|
|
8641
8833
|
this.log = opts.log ?? (() => {
|
|
@@ -8672,10 +8864,12 @@ var RelayLink = class {
|
|
|
8672
8864
|
const socket = this.socket;
|
|
8673
8865
|
const identity = this.identity;
|
|
8674
8866
|
if (this.currentStatus !== "connected" || !socket || !identity) return;
|
|
8675
|
-
|
|
8676
|
-
|
|
8867
|
+
this.outbound = this.outbound.then(async () => {
|
|
8868
|
+
const payload = await seal(identity, plaintext);
|
|
8869
|
+
if (this.socket === socket && socket.readyState === WebSocket.OPEN) {
|
|
8677
8870
|
socket.send(JSON.stringify({ v: 1, type: "frame", payload }));
|
|
8678
8871
|
}
|
|
8872
|
+
}).catch(() => {
|
|
8679
8873
|
});
|
|
8680
8874
|
}
|
|
8681
8875
|
async connect() {
|
|
@@ -8714,9 +8908,12 @@ var RelayLink = class {
|
|
|
8714
8908
|
return;
|
|
8715
8909
|
}
|
|
8716
8910
|
if (message.type === "frame" && typeof message.payload === "string") {
|
|
8717
|
-
|
|
8911
|
+
const payload = message.payload;
|
|
8912
|
+
this.inbound = this.inbound.then(async () => {
|
|
8913
|
+
const plaintext = await open(identity, payload);
|
|
8718
8914
|
if (plaintext !== null) this.opts.onMessage(plaintext);
|
|
8719
8915
|
else this.log("dropped a relay frame that failed to open");
|
|
8916
|
+
}).catch(() => {
|
|
8720
8917
|
});
|
|
8721
8918
|
return;
|
|
8722
8919
|
}
|
|
@@ -9958,6 +10155,7 @@ var LongLeashServer = class {
|
|
|
9958
10155
|
external = null;
|
|
9959
10156
|
delegations = null;
|
|
9960
10157
|
hookSecret = null;
|
|
10158
|
+
localPairing = null;
|
|
9961
10159
|
staticRoot;
|
|
9962
10160
|
/** Captured at process start so updating files on disk cannot impersonate a daemon restart. */
|
|
9963
10161
|
expectedBuild;
|
|
@@ -10085,6 +10283,15 @@ var LongLeashServer = class {
|
|
|
10085
10283
|
this.log(`device ${deviceId} revoked \u2014 its sessions are closed and its relay room is shut`);
|
|
10086
10284
|
return { revoked: true, deviceId };
|
|
10087
10285
|
});
|
|
10286
|
+
this.app.post("/local/pairing", async (request, reply) => {
|
|
10287
|
+
if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
|
|
10288
|
+
if (this.localPairing === null) return reply.code(503).send({ reason: "pairing-unavailable" });
|
|
10289
|
+
try {
|
|
10290
|
+
return { url: this.localPairing() };
|
|
10291
|
+
} catch {
|
|
10292
|
+
return reply.code(503).send({ reason: "pairing-failed" });
|
|
10293
|
+
}
|
|
10294
|
+
});
|
|
10088
10295
|
this.app.get("/pair", async (_request, reply) => reply.code(405).send({ reason: "use-post" }));
|
|
10089
10296
|
this.app.post("/pair", async (request, reply) => {
|
|
10090
10297
|
const query2 = request.query;
|
|
@@ -10190,15 +10397,15 @@ var LongLeashServer = class {
|
|
|
10190
10397
|
*/
|
|
10191
10398
|
async rebind(host) {
|
|
10192
10399
|
const wanted = this.boundPort;
|
|
10193
|
-
await new Promise((
|
|
10194
|
-
const tryListen = (port) => new Promise((
|
|
10400
|
+
await new Promise((resolve7) => this.app.server.close(() => resolve7()));
|
|
10401
|
+
const tryListen = (port) => new Promise((resolve7, reject) => {
|
|
10195
10402
|
const onError = (err) => {
|
|
10196
10403
|
this.app.server.removeListener("listening", onListening);
|
|
10197
10404
|
reject(err);
|
|
10198
10405
|
};
|
|
10199
10406
|
const onListening = () => {
|
|
10200
10407
|
this.app.server.removeListener("error", onError);
|
|
10201
|
-
|
|
10408
|
+
resolve7();
|
|
10202
10409
|
};
|
|
10203
10410
|
this.app.server.once("error", onError);
|
|
10204
10411
|
this.app.server.once("listening", onListening);
|
|
@@ -10223,6 +10430,10 @@ var LongLeashServer = class {
|
|
|
10223
10430
|
this.external = external;
|
|
10224
10431
|
this.hookSecret = hookSecret;
|
|
10225
10432
|
}
|
|
10433
|
+
/** A laptop-local CLI may request a transient QR without persisting its secret in service logs. */
|
|
10434
|
+
attachLocalPairing(create) {
|
|
10435
|
+
this.localPairing = create;
|
|
10436
|
+
}
|
|
10226
10437
|
/** Persist an event, then fan it out to every subscriber of that session. */
|
|
10227
10438
|
publish(sessionId, input) {
|
|
10228
10439
|
const event2 = this.eventLog.append(sessionId, input);
|
|
@@ -10333,6 +10544,9 @@ var LongLeashServer = class {
|
|
|
10333
10544
|
}
|
|
10334
10545
|
/** Tell the client what it may do, so it never has to guess a project path. */
|
|
10335
10546
|
sendHello(connection) {
|
|
10547
|
+
const bySession = new Map(
|
|
10548
|
+
[...this.sessions?.listSessions() ?? [], ...this.external?.listSessions() ?? []].map((session) => [session.sessionId, session])
|
|
10549
|
+
);
|
|
10336
10550
|
this.sendTo(connection, {
|
|
10337
10551
|
v: PROTOCOL_VERSION,
|
|
10338
10552
|
type: "hello",
|
|
@@ -10340,7 +10554,11 @@ var LongLeashServer = class {
|
|
|
10340
10554
|
roots: this.sessions?.listAllowedRoots() ?? [],
|
|
10341
10555
|
// Everything the daemon knows about, so a reloaded phone rebuilds instead of showing
|
|
10342
10556
|
// nothing — including sessions the human started in a terminal.
|
|
10343
|
-
sessions: [...
|
|
10557
|
+
sessions: [...bySession.values()].map((session) => ({
|
|
10558
|
+
...session,
|
|
10559
|
+
title: this.eventLog.aliasFor(session.sessionId) ?? session.title,
|
|
10560
|
+
lastActivityAt: this.eventLog.latestTimestamp(session.sessionId) || session.startedAt
|
|
10561
|
+
})).sort((left, right) => left.startedAt - right.startedAt || left.sessionId.localeCompare(right.sessionId)),
|
|
10344
10562
|
capabilities: {
|
|
10345
10563
|
startSession: this.sessions !== null,
|
|
10346
10564
|
stopSession: this.sessions !== null,
|
|
@@ -10418,6 +10636,15 @@ var LongLeashServer = class {
|
|
|
10418
10636
|
return;
|
|
10419
10637
|
}
|
|
10420
10638
|
for (const event2 of replay.events) this.sendTo(connection, event2);
|
|
10639
|
+
if (message.syncId !== void 0) {
|
|
10640
|
+
this.sendTo(connection, {
|
|
10641
|
+
v: PROTOCOL_VERSION,
|
|
10642
|
+
type: "sync.complete",
|
|
10643
|
+
sessionId: message.sessionId,
|
|
10644
|
+
syncId: message.syncId,
|
|
10645
|
+
cursor: this.eventLog.latestSeq(message.sessionId)
|
|
10646
|
+
});
|
|
10647
|
+
}
|
|
10421
10648
|
return;
|
|
10422
10649
|
}
|
|
10423
10650
|
if (message.type === "pushSubscribe") {
|
|
@@ -10703,8 +10930,60 @@ var LongLeashServer = class {
|
|
|
10703
10930
|
});
|
|
10704
10931
|
return;
|
|
10705
10932
|
}
|
|
10933
|
+
if (message.type === "renameSession") {
|
|
10934
|
+
const title = message.title.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
10935
|
+
const externalRenamed = this.external?.renameSession(message.sessionId, title) === true;
|
|
10936
|
+
const managedRenamed = this.sessions.renameSession(message.sessionId, title, !externalRenamed);
|
|
10937
|
+
const renamed = externalRenamed || managedRenamed;
|
|
10938
|
+
this.log(`rename ${message.sessionId} -> ${renamed ? "renamed" : "unknown"}`);
|
|
10939
|
+
this.sendTo(connection, {
|
|
10940
|
+
v: PROTOCOL_VERSION,
|
|
10941
|
+
type: "ack",
|
|
10942
|
+
of: "renameSession",
|
|
10943
|
+
sessionId: message.sessionId,
|
|
10944
|
+
outcome: renamed ? "renamed" : "unknown-session"
|
|
10945
|
+
});
|
|
10946
|
+
if (!renamed) {
|
|
10947
|
+
this.sendTo(connection, {
|
|
10948
|
+
v: PROTOCOL_VERSION,
|
|
10949
|
+
type: "error",
|
|
10950
|
+
code: "unknown-session",
|
|
10951
|
+
message: "This conversation is no longer known to the laptop, so its name was not changed."
|
|
10952
|
+
});
|
|
10953
|
+
}
|
|
10954
|
+
return;
|
|
10955
|
+
}
|
|
10956
|
+
if (message.type === "reclaimSession") {
|
|
10957
|
+
const sessionManager = this.sessions;
|
|
10958
|
+
void (async () => {
|
|
10959
|
+
const outcome = await this.external?.reclaimToPhone(message.sessionId, connection.deviceId) ?? "not-found";
|
|
10960
|
+
let reopened = false;
|
|
10961
|
+
if (outcome === "reclaimed") {
|
|
10962
|
+
reopened = await sessionManager.resumeSession(message.sessionId, connection.deviceId);
|
|
10963
|
+
}
|
|
10964
|
+
const success = outcome === "reclaimed" && reopened;
|
|
10965
|
+
this.log(`reclaim ${message.sessionId} -> ${success ? "phone-ready" : outcome}`);
|
|
10966
|
+
this.sendTo(connection, {
|
|
10967
|
+
v: PROTOCOL_VERSION,
|
|
10968
|
+
type: "ack",
|
|
10969
|
+
of: "reclaimSession",
|
|
10970
|
+
sessionId: message.sessionId,
|
|
10971
|
+
outcome: success ? "phone-ready" : outcome
|
|
10972
|
+
});
|
|
10973
|
+
if (!success) {
|
|
10974
|
+
const detail = outcome === "observe-only" ? "This VS Code conversation is visible, but its lifecycle hook has not provided a verified process handle. Stop it in VS Code; LongLeash will make the same conversation reopenable when SessionEnd arrives." : outcome === "unverified" ? "LongLeash could not verify that the Terminal or VS Code writer exited, so it kept control there. Nothing was started on the phone." : outcome === "reclaimed" ? "The native writer ended, but this provider did not expose a recoverable conversation point. Nothing new was started." : "This native session is no longer connected to the laptop daemon.";
|
|
10975
|
+
this.sendTo(connection, {
|
|
10976
|
+
v: PROTOCOL_VERSION,
|
|
10977
|
+
type: "error",
|
|
10978
|
+
code: "cannot-reclaim",
|
|
10979
|
+
message: detail
|
|
10980
|
+
});
|
|
10981
|
+
}
|
|
10982
|
+
})();
|
|
10983
|
+
return;
|
|
10984
|
+
}
|
|
10706
10985
|
if (message.type === "setGate") {
|
|
10707
|
-
const applied = this.external !== null && message.sessionId
|
|
10986
|
+
const applied = this.external !== null && this.external.hasLiveSession(message.sessionId) ? this.external.setGate(message.sessionId, message.gate) : false;
|
|
10708
10987
|
this.log(`gate ${message.sessionId} -> ${message.gate}${applied ? "" : " (unknown session)"}`);
|
|
10709
10988
|
this.sendTo(connection, {
|
|
10710
10989
|
v: PROTOCOL_VERSION,
|
|
@@ -10721,6 +11000,12 @@ var LongLeashServer = class {
|
|
|
10721
11000
|
try {
|
|
10722
11001
|
const liveExternal = this.external?.hasLiveSession(message.sessionId) === true;
|
|
10723
11002
|
const externalSession = liveExternal ? this.external?.listSessions().find((item) => item.sessionId === message.sessionId) : void 0;
|
|
11003
|
+
if (externalSession?.control === "observe") {
|
|
11004
|
+
throw new SessionError(
|
|
11005
|
+
"session-busy",
|
|
11006
|
+
"This Codex conversation is visible through its active VS Code transcript, but its process has not exposed a control hook yet. Continue or stop it in VS Code; LongLeash changed nothing."
|
|
11007
|
+
);
|
|
11008
|
+
}
|
|
10724
11009
|
if (externalSession?.agent === "codex" && message.settings.thinking !== void 0) {
|
|
10725
11010
|
throw new SessionError(
|
|
10726
11011
|
"unsupported-setting",
|
|
@@ -10836,7 +11121,7 @@ var LongLeashServer = class {
|
|
|
10836
11121
|
return;
|
|
10837
11122
|
}
|
|
10838
11123
|
if (message.type === "stopSession") {
|
|
10839
|
-
if (message.sessionId
|
|
11124
|
+
if (this.external?.hasLiveSession(message.sessionId) === true) {
|
|
10840
11125
|
const sessions = this.sessions;
|
|
10841
11126
|
void this.external.stop(message.sessionId, connection.deviceId).then((stopped) => {
|
|
10842
11127
|
if (stopped) {
|
|
@@ -11044,6 +11329,11 @@ function normalizeRelayUrl(raw) {
|
|
|
11044
11329
|
|
|
11045
11330
|
// ../daemon/src/adapters/claude.ts
|
|
11046
11331
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
11332
|
+
function permissionMode(mode) {
|
|
11333
|
+
if (mode === "auto") return "auto";
|
|
11334
|
+
if (mode === "plan") return "plan";
|
|
11335
|
+
return "default";
|
|
11336
|
+
}
|
|
11047
11337
|
function summarizeToolInput(input) {
|
|
11048
11338
|
if (input && typeof input === "object") {
|
|
11049
11339
|
const record = input;
|
|
@@ -11077,8 +11367,8 @@ function createClaudeAgentFactory(options = {}) {
|
|
|
11077
11367
|
};
|
|
11078
11368
|
}
|
|
11079
11369
|
if (closed) return;
|
|
11080
|
-
await new Promise((
|
|
11081
|
-
wake =
|
|
11370
|
+
await new Promise((resolve7) => {
|
|
11371
|
+
wake = resolve7;
|
|
11082
11372
|
});
|
|
11083
11373
|
}
|
|
11084
11374
|
}
|
|
@@ -11101,6 +11391,7 @@ function createClaudeAgentFactory(options = {}) {
|
|
|
11101
11391
|
...request.settings?.model === void 0 && options.model === void 0 ? {} : { model: request.settings?.model ?? options.model },
|
|
11102
11392
|
...request.settings?.effort === void 0 ? {} : { effort: request.settings.effort },
|
|
11103
11393
|
...request.settings?.thinking === void 0 ? {} : request.settings.thinking.mode === "fixed" ? { thinking: { type: "enabled", budgetTokens: request.settings.thinking.budgetTokens } } : request.settings.thinking.mode === "adaptive" ? { thinking: { type: "adaptive" } } : { thinking: { type: "disabled" } },
|
|
11394
|
+
permissionMode: permissionMode(request.settings?.mode),
|
|
11104
11395
|
...options.allowedTools === void 0 ? {} : { allowedTools: options.allowedTools },
|
|
11105
11396
|
...options.isolateFromUserSettings ? { settingSources: [] } : {},
|
|
11106
11397
|
// Reopening a closed conversation: Claude replays its own transcript, so the agent
|
|
@@ -11143,6 +11434,7 @@ function createClaudeAgentFactory(options = {}) {
|
|
|
11143
11434
|
});
|
|
11144
11435
|
let activeSettings = { ...request.settings ?? {} };
|
|
11145
11436
|
const applySettings = async (settings) => {
|
|
11437
|
+
await run.setPermissionMode(permissionMode(settings.mode));
|
|
11146
11438
|
await run.setModel(settings.model);
|
|
11147
11439
|
await run.applyFlagSettings({ effortLevel: settings.effort ?? null });
|
|
11148
11440
|
if (settings.thinking === void 0) {
|
|
@@ -11301,6 +11593,32 @@ var CodexRun = class {
|
|
|
11301
11593
|
log;
|
|
11302
11594
|
/** Per-turn overrides; updating this affects the next turn without replacing the thread. */
|
|
11303
11595
|
settings;
|
|
11596
|
+
approvalPolicy() {
|
|
11597
|
+
if (this.settings.mode === "auto") return "on-request";
|
|
11598
|
+
if (this.settings.mode === "manual" || this.settings.mode === "plan") return "untrusted";
|
|
11599
|
+
return this.opts.approvalPolicy ?? "untrusted";
|
|
11600
|
+
}
|
|
11601
|
+
sandbox() {
|
|
11602
|
+
if (this.settings.mode === "plan") return "read-only";
|
|
11603
|
+
if (this.settings.mode === "manual" || this.settings.mode === "auto") return "workspace-write";
|
|
11604
|
+
return this.opts.sandbox ?? "workspace-write";
|
|
11605
|
+
}
|
|
11606
|
+
sandboxPolicy() {
|
|
11607
|
+
const sandbox = this.sandbox();
|
|
11608
|
+
if (sandbox === "read-only") return { type: "readOnly", networkAccess: false };
|
|
11609
|
+
if (sandbox === "danger-full-access") return { type: "dangerFullAccess" };
|
|
11610
|
+
return { type: "workspaceWrite", networkAccess: false, writableRoots: [this.request.cwd] };
|
|
11611
|
+
}
|
|
11612
|
+
providerText(text) {
|
|
11613
|
+
if (this.settings.mode !== "plan") return text;
|
|
11614
|
+
return [
|
|
11615
|
+
"<longleash-plan-mode>",
|
|
11616
|
+
"Plan only. Investigate and explain a concrete implementation approach, but do not modify files or run commands that change the workspace. Ask for clarification when a decision is required.",
|
|
11617
|
+
"</longleash-plan-mode>",
|
|
11618
|
+
"",
|
|
11619
|
+
text
|
|
11620
|
+
].join("\n");
|
|
11621
|
+
}
|
|
11304
11622
|
handle() {
|
|
11305
11623
|
return {
|
|
11306
11624
|
events: this.iterate(),
|
|
@@ -11309,7 +11627,7 @@ var CodexRun = class {
|
|
|
11309
11627
|
if (this.threadId !== null && this.turnId !== null) {
|
|
11310
11628
|
void this.call("turn/interrupt", { threadId: this.threadId, turnId: this.turnId }).catch(() => {
|
|
11311
11629
|
});
|
|
11312
|
-
await new Promise((
|
|
11630
|
+
await new Promise((resolve7) => setTimeout(resolve7, INTERRUPT_GRACE_MS));
|
|
11313
11631
|
}
|
|
11314
11632
|
this.child.kill("SIGTERM");
|
|
11315
11633
|
this.finish();
|
|
@@ -11335,15 +11653,15 @@ var CodexRun = class {
|
|
|
11335
11653
|
this.request.resume === void 0 ? {
|
|
11336
11654
|
cwd: this.request.cwd,
|
|
11337
11655
|
...this.settings.model === void 0 ? {} : { model: this.settings.model },
|
|
11338
|
-
approvalPolicy: this.
|
|
11339
|
-
sandbox: this.
|
|
11656
|
+
approvalPolicy: this.approvalPolicy(),
|
|
11657
|
+
sandbox: this.sandbox(),
|
|
11340
11658
|
config: MANAGED_THREAD_CONFIG
|
|
11341
11659
|
} : {
|
|
11342
11660
|
threadId: this.request.resume,
|
|
11343
11661
|
cwd: this.request.cwd,
|
|
11344
11662
|
...this.settings.model === void 0 ? {} : { model: this.settings.model },
|
|
11345
|
-
approvalPolicy: this.
|
|
11346
|
-
sandbox: this.
|
|
11663
|
+
approvalPolicy: this.approvalPolicy(),
|
|
11664
|
+
sandbox: this.sandbox(),
|
|
11347
11665
|
config: MANAGED_THREAD_CONFIG
|
|
11348
11666
|
}
|
|
11349
11667
|
);
|
|
@@ -11365,9 +11683,11 @@ var CodexRun = class {
|
|
|
11365
11683
|
try {
|
|
11366
11684
|
await this.call("turn/start", {
|
|
11367
11685
|
threadId: this.threadId,
|
|
11368
|
-
input: [{ type: "text", text }],
|
|
11686
|
+
input: [{ type: "text", text: this.providerText(text) }],
|
|
11369
11687
|
...this.settings.model === void 0 ? {} : { model: this.settings.model },
|
|
11370
|
-
...this.settings.effort === void 0 ? {} : { effort: this.settings.effort }
|
|
11688
|
+
...this.settings.effort === void 0 ? {} : { effort: this.settings.effort },
|
|
11689
|
+
approvalPolicy: this.approvalPolicy(),
|
|
11690
|
+
sandboxPolicy: this.sandboxPolicy()
|
|
11371
11691
|
});
|
|
11372
11692
|
} catch (error) {
|
|
11373
11693
|
if (fatal) throw error;
|
|
@@ -11527,8 +11847,8 @@ var CodexRun = class {
|
|
|
11527
11847
|
// -------------------------------------------------------------------- plumbing
|
|
11528
11848
|
call(method, params) {
|
|
11529
11849
|
const id2 = this.nextId++;
|
|
11530
|
-
const promise = new Promise((
|
|
11531
|
-
this.pending.set(id2, { resolve:
|
|
11850
|
+
const promise = new Promise((resolve7, reject) => {
|
|
11851
|
+
this.pending.set(id2, { resolve: resolve7, reject });
|
|
11532
11852
|
});
|
|
11533
11853
|
this.write({ jsonrpc: "2.0", id: id2, method, params });
|
|
11534
11854
|
return promise;
|
|
@@ -11580,8 +11900,8 @@ var CodexRun = class {
|
|
|
11580
11900
|
if (this.failure !== null) throw this.failure;
|
|
11581
11901
|
return;
|
|
11582
11902
|
}
|
|
11583
|
-
await new Promise((
|
|
11584
|
-
this.waiter =
|
|
11903
|
+
await new Promise((resolve7) => {
|
|
11904
|
+
this.waiter = resolve7;
|
|
11585
11905
|
});
|
|
11586
11906
|
}
|
|
11587
11907
|
}
|
|
@@ -11763,8 +12083,8 @@ var FolderIndex = class {
|
|
|
11763
12083
|
for (const root of this.roots) {
|
|
11764
12084
|
if (path === root) return basename4(root);
|
|
11765
12085
|
if (path.startsWith(root + sep4)) {
|
|
11766
|
-
const
|
|
11767
|
-
return `${basename4(root)}/${
|
|
12086
|
+
const relative3 = path.slice(root.length + 1);
|
|
12087
|
+
return `${basename4(root)}/${relative3}`;
|
|
11768
12088
|
}
|
|
11769
12089
|
}
|
|
11770
12090
|
return path;
|
|
@@ -12170,13 +12490,210 @@ function roleName2(role) {
|
|
|
12170
12490
|
return role.slice(0, 1).toUpperCase() + role.slice(1);
|
|
12171
12491
|
}
|
|
12172
12492
|
|
|
12493
|
+
// ../daemon/src/codex-session-watch.ts
|
|
12494
|
+
import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
|
|
12495
|
+
import { homedir as homedir3 } from "node:os";
|
|
12496
|
+
import { isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve as resolve4 } from "node:path";
|
|
12497
|
+
var MAX_HEAD = 256 * 1024;
|
|
12498
|
+
var MAX_TAIL = 1024 * 1024;
|
|
12499
|
+
var MAX_INITIAL_TAIL = 64 * 1024 * 1024;
|
|
12500
|
+
function readSlice(path, offset, length) {
|
|
12501
|
+
const fd = openSync2(path, "r");
|
|
12502
|
+
try {
|
|
12503
|
+
const buffer = Buffer.alloc(length);
|
|
12504
|
+
const read = readSync2(fd, buffer, 0, length, offset);
|
|
12505
|
+
return buffer.subarray(0, read).toString("utf8");
|
|
12506
|
+
} finally {
|
|
12507
|
+
closeSync2(fd);
|
|
12508
|
+
}
|
|
12509
|
+
}
|
|
12510
|
+
function inside(root, candidate) {
|
|
12511
|
+
const rel = relative2(resolve4(root), resolve4(candidate));
|
|
12512
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
12513
|
+
}
|
|
12514
|
+
function inspectCodexTranscript(path, roots2, maxTailBytes = MAX_INITIAL_TAIL) {
|
|
12515
|
+
let size;
|
|
12516
|
+
let activityAt;
|
|
12517
|
+
try {
|
|
12518
|
+
const stats = statSync2(path);
|
|
12519
|
+
size = stats.size;
|
|
12520
|
+
activityAt = stats.mtimeMs;
|
|
12521
|
+
} catch {
|
|
12522
|
+
return null;
|
|
12523
|
+
}
|
|
12524
|
+
try {
|
|
12525
|
+
const firstLine = readSlice(path, 0, Math.min(size, MAX_HEAD)).split("\n")[0];
|
|
12526
|
+
if (!firstLine) return null;
|
|
12527
|
+
const meta = JSON.parse(firstLine);
|
|
12528
|
+
if (meta.type !== "session_meta" || !meta.payload) return null;
|
|
12529
|
+
const sessionId = typeof meta.payload.session_id === "string" ? meta.payload.session_id : typeof meta.payload.id === "string" ? meta.payload.id : null;
|
|
12530
|
+
const cwd = typeof meta.payload.cwd === "string" ? meta.payload.cwd : null;
|
|
12531
|
+
if (sessionId === null || cwd === null || !roots2.some((root) => inside(root, cwd))) return null;
|
|
12532
|
+
if (meta.payload.source !== "vscode" && meta.payload.originator !== "codex_vscode") return null;
|
|
12533
|
+
const surface = "vscode";
|
|
12534
|
+
const tailOffset = Math.max(0, size - maxTailBytes);
|
|
12535
|
+
const tailText = readSlice(path, tailOffset, size - tailOffset);
|
|
12536
|
+
const lines = tailText.split("\n");
|
|
12537
|
+
if (tailOffset > 0) lines.shift();
|
|
12538
|
+
let title;
|
|
12539
|
+
const snapshot = [];
|
|
12540
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
12541
|
+
const line = lines[index];
|
|
12542
|
+
if (!line?.includes('"role":"user"')) continue;
|
|
12543
|
+
try {
|
|
12544
|
+
const record = JSON.parse(line);
|
|
12545
|
+
if (record.type !== "response_item" || record.payload?.type !== "message" || record.payload.role !== "user") continue;
|
|
12546
|
+
const content = record.payload.content;
|
|
12547
|
+
if (!Array.isArray(content)) continue;
|
|
12548
|
+
const text = content.filter((item) => !!item && typeof item === "object" && item.type === "input_text" && typeof item.text === "string").map((item) => item.text).join("\n");
|
|
12549
|
+
title = titleFrom(text) ?? void 0;
|
|
12550
|
+
if (title !== void 0) break;
|
|
12551
|
+
} catch {
|
|
12552
|
+
}
|
|
12553
|
+
}
|
|
12554
|
+
for (const line of lines) {
|
|
12555
|
+
if (line.length === 0 || line.length > 1e6) continue;
|
|
12556
|
+
try {
|
|
12557
|
+
for (const delta of transcriptDeltas(JSON.parse(line))) {
|
|
12558
|
+
if (delta.type !== "stream.delta" || delta.payload.text.trim() === "") continue;
|
|
12559
|
+
snapshot.push({ kind: delta.payload.kind, text: delta.payload.text });
|
|
12560
|
+
}
|
|
12561
|
+
} catch {
|
|
12562
|
+
}
|
|
12563
|
+
}
|
|
12564
|
+
let retained = snapshot.slice(-80);
|
|
12565
|
+
let characters = retained.reduce((sum, block) => sum + block.text.length, 0);
|
|
12566
|
+
while (retained.length > 1 && characters > 12e4) {
|
|
12567
|
+
characters -= retained.shift()?.text.length ?? 0;
|
|
12568
|
+
}
|
|
12569
|
+
if (title === void 0) title = titleFrom(latestUserText(tailText) ?? "") ?? void 0;
|
|
12570
|
+
return {
|
|
12571
|
+
sessionId,
|
|
12572
|
+
cwd,
|
|
12573
|
+
transcriptPath: path,
|
|
12574
|
+
surface,
|
|
12575
|
+
activityAt,
|
|
12576
|
+
snapshot: retained,
|
|
12577
|
+
...title === void 0 ? {} : { title }
|
|
12578
|
+
};
|
|
12579
|
+
} catch {
|
|
12580
|
+
return null;
|
|
12581
|
+
}
|
|
12582
|
+
}
|
|
12583
|
+
var CodexSessionWatcher = class {
|
|
12584
|
+
options;
|
|
12585
|
+
mtimes = /* @__PURE__ */ new Map();
|
|
12586
|
+
known = /* @__PURE__ */ new Map();
|
|
12587
|
+
timer = null;
|
|
12588
|
+
constructor(options) {
|
|
12589
|
+
this.options = { pollMs: 3e3, initialRecentMs: 5 * 6e4, ...options };
|
|
12590
|
+
}
|
|
12591
|
+
start() {
|
|
12592
|
+
this.scan(true);
|
|
12593
|
+
this.timer = setInterval(() => this.scan(false), this.options.pollMs);
|
|
12594
|
+
this.timer.unref?.();
|
|
12595
|
+
return () => this.stop();
|
|
12596
|
+
}
|
|
12597
|
+
stop() {
|
|
12598
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
12599
|
+
this.timer = null;
|
|
12600
|
+
}
|
|
12601
|
+
scan(initial = false) {
|
|
12602
|
+
const root = this.options.sessionsRoot ?? join6(homedir3(), ".codex", "sessions");
|
|
12603
|
+
const now = this.options.now?.() ?? Date.now();
|
|
12604
|
+
let observed = 0;
|
|
12605
|
+
for (const path of jsonlFiles(root)) {
|
|
12606
|
+
let modified;
|
|
12607
|
+
try {
|
|
12608
|
+
modified = statSync2(path).mtimeMs;
|
|
12609
|
+
} catch {
|
|
12610
|
+
continue;
|
|
12611
|
+
}
|
|
12612
|
+
const previous = this.mtimes.get(path);
|
|
12613
|
+
this.mtimes.set(path, modified);
|
|
12614
|
+
const changed = previous !== void 0 && modified > previous;
|
|
12615
|
+
const recentlyActive = initial && now - modified <= this.options.initialRecentMs;
|
|
12616
|
+
if (!changed && !recentlyActive) continue;
|
|
12617
|
+
const previousSession = this.known.get(path);
|
|
12618
|
+
const inspected = inspectCodexTranscript(
|
|
12619
|
+
path,
|
|
12620
|
+
this.options.roots,
|
|
12621
|
+
previousSession === void 0 ? MAX_INITIAL_TAIL : MAX_TAIL
|
|
12622
|
+
);
|
|
12623
|
+
const session = inspected === null ? null : inspected.title === void 0 && previousSession?.title !== void 0 ? { ...inspected, title: previousSession.title } : inspected;
|
|
12624
|
+
if (session === null) continue;
|
|
12625
|
+
this.known.set(path, session);
|
|
12626
|
+
this.options.onSession(session);
|
|
12627
|
+
observed += 1;
|
|
12628
|
+
}
|
|
12629
|
+
return observed;
|
|
12630
|
+
}
|
|
12631
|
+
};
|
|
12632
|
+
function jsonStringAt(source, quoteAt) {
|
|
12633
|
+
if (source[quoteAt] !== '"') return null;
|
|
12634
|
+
let escaped = false;
|
|
12635
|
+
for (let index = quoteAt + 1; index < source.length; index += 1) {
|
|
12636
|
+
const char = source[index];
|
|
12637
|
+
if (escaped) {
|
|
12638
|
+
escaped = false;
|
|
12639
|
+
continue;
|
|
12640
|
+
}
|
|
12641
|
+
if (char === "\\") {
|
|
12642
|
+
escaped = true;
|
|
12643
|
+
continue;
|
|
12644
|
+
}
|
|
12645
|
+
if (char === '"') {
|
|
12646
|
+
try {
|
|
12647
|
+
return JSON.parse(source.slice(quoteAt, index + 1));
|
|
12648
|
+
} catch {
|
|
12649
|
+
return null;
|
|
12650
|
+
}
|
|
12651
|
+
}
|
|
12652
|
+
}
|
|
12653
|
+
return null;
|
|
12654
|
+
}
|
|
12655
|
+
function latestUserText(source) {
|
|
12656
|
+
let before = source.length;
|
|
12657
|
+
while (before > 0) {
|
|
12658
|
+
const roleAt = source.lastIndexOf('"role":"user"', before);
|
|
12659
|
+
if (roleAt < 0) return null;
|
|
12660
|
+
const textKey = source.indexOf('"text":', roleAt);
|
|
12661
|
+
const nextRole = source.indexOf('"role":', roleAt + 1);
|
|
12662
|
+
if (textKey >= 0 && (nextRole < 0 || textKey < nextRole)) {
|
|
12663
|
+
const quoteAt = source.indexOf('"', textKey + 7);
|
|
12664
|
+
const text = quoteAt < 0 ? null : jsonStringAt(source, quoteAt);
|
|
12665
|
+
if (text !== null && titleFrom(text) !== null) return text;
|
|
12666
|
+
}
|
|
12667
|
+
before = roleAt;
|
|
12668
|
+
}
|
|
12669
|
+
return null;
|
|
12670
|
+
}
|
|
12671
|
+
function jsonlFiles(root) {
|
|
12672
|
+
const out = [];
|
|
12673
|
+
const visit = (directory) => {
|
|
12674
|
+
let entries;
|
|
12675
|
+
try {
|
|
12676
|
+
entries = readdirSync3(directory, { withFileTypes: true });
|
|
12677
|
+
} catch {
|
|
12678
|
+
return;
|
|
12679
|
+
}
|
|
12680
|
+
for (const entry of entries) {
|
|
12681
|
+
const path = join6(directory, entry.name);
|
|
12682
|
+
if (entry.isDirectory()) visit(path);
|
|
12683
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(path);
|
|
12684
|
+
}
|
|
12685
|
+
};
|
|
12686
|
+
visit(root);
|
|
12687
|
+
return out;
|
|
12688
|
+
}
|
|
12689
|
+
|
|
12173
12690
|
// ../daemon/src/daemon.ts
|
|
12174
12691
|
var DEFAULT_READ_ONLY_TOOLS = ["Read", "Glob", "Grep"];
|
|
12175
12692
|
function executableOnPath(command) {
|
|
12176
12693
|
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
12177
12694
|
if (directory === "") continue;
|
|
12178
12695
|
try {
|
|
12179
|
-
accessSync(
|
|
12696
|
+
accessSync(join7(directory, command), constants.X_OK);
|
|
12180
12697
|
return true;
|
|
12181
12698
|
} catch {
|
|
12182
12699
|
}
|
|
@@ -12184,20 +12701,20 @@ function executableOnPath(command) {
|
|
|
12184
12701
|
return false;
|
|
12185
12702
|
}
|
|
12186
12703
|
async function startDaemon(options) {
|
|
12187
|
-
const dataDir2 = options.dataDir ??
|
|
12704
|
+
const dataDir2 = options.dataDir ?? join7(homedir4(), ".longleash");
|
|
12188
12705
|
mkdirSync3(dataDir2, { recursive: true });
|
|
12189
|
-
const roots2 = options.allowedRoots.map((root) =>
|
|
12706
|
+
const roots2 = options.allowedRoots.map((root) => resolve5(root));
|
|
12190
12707
|
for (const root of roots2) {
|
|
12191
12708
|
if (!existsSync6(root)) throw new Error(`Allowed root does not exist: ${root}`);
|
|
12192
12709
|
}
|
|
12193
|
-
const eventLog = new EventLog(
|
|
12194
|
-
const registry = new DeviceRegistry(
|
|
12195
|
-
const approvals = new ApprovalStore(
|
|
12710
|
+
const eventLog = new EventLog(join7(dataDir2, "events.db"));
|
|
12711
|
+
const registry = new DeviceRegistry(join7(dataDir2, "devices.db"));
|
|
12712
|
+
const approvals = new ApprovalStore(join7(dataDir2, "approvals.db"));
|
|
12196
12713
|
const workspace = new WorkspaceLeaseManager(approvals.rawDb);
|
|
12197
|
-
const worktrees = new WorktreeManager(
|
|
12714
|
+
const worktrees = new WorktreeManager(join7(dataDir2, "worktrees"));
|
|
12198
12715
|
const push = new PushNotifier({
|
|
12199
|
-
dbPath:
|
|
12200
|
-
keysPath:
|
|
12716
|
+
dbPath: join7(dataDir2, "push.db"),
|
|
12717
|
+
keysPath: join7(dataDir2, "vapid.json"),
|
|
12201
12718
|
// Who a push service may contact about this sender: the relay origin when
|
|
12202
12719
|
// one exists, else a placeholder mailto (LAN-only installs never push far).
|
|
12203
12720
|
subject: process.env.LONGLEASH_PUSH_SUBJECT ?? (options.relayUrl !== void 0 ? options.relayUrl.replace(/^ws(s?):\/\//, "http$1://").replace(/\/ws\/?$/, "") : "mailto:longleash@localhost.invalid"),
|
|
@@ -12283,14 +12800,15 @@ async function startDaemon(options) {
|
|
|
12283
12800
|
server.attachSessions(sessions);
|
|
12284
12801
|
server.attachFolders(new FolderIndex(roots2));
|
|
12285
12802
|
server.attachPush(push);
|
|
12286
|
-
const externalApprovals = new ApprovalStore(
|
|
12287
|
-
const sessionRegistry = new SessionRegistry(
|
|
12803
|
+
const externalApprovals = new ApprovalStore(join7(dataDir2, "approvals-external.db"));
|
|
12804
|
+
const sessionRegistry = new SessionRegistry(join7(dataDir2, "live-sessions.db"));
|
|
12288
12805
|
const external = new ExternalSessions({
|
|
12289
12806
|
eventLog,
|
|
12290
12807
|
approvals: externalApprovals,
|
|
12291
12808
|
registry: sessionRegistry,
|
|
12292
12809
|
onEvent: mirror,
|
|
12293
12810
|
workspace,
|
|
12811
|
+
resolveSessionId: (agentSessionId) => sessions.sessionIdForAgentSession(agentSessionId),
|
|
12294
12812
|
// A live socket means the app is open and can answer in seconds. A push REGISTRATION is
|
|
12295
12813
|
// permanent and proves nothing about whether anyone is looking — treating it as presence
|
|
12296
12814
|
// is what froze the keyboard for two minutes with the phone in a drawer.
|
|
@@ -12307,10 +12825,15 @@ async function startDaemon(options) {
|
|
|
12307
12825
|
agentSessionId: info.claudeSessionId
|
|
12308
12826
|
})
|
|
12309
12827
|
});
|
|
12828
|
+
const codexWatcher = new CodexSessionWatcher({
|
|
12829
|
+
roots: roots2,
|
|
12830
|
+
onSession: (session) => external.observeCodexSession(session)
|
|
12831
|
+
});
|
|
12832
|
+
codexWatcher.start();
|
|
12310
12833
|
external.startSweeping();
|
|
12311
12834
|
const delegationStore = new DelegationStore(approvals.rawDb);
|
|
12312
12835
|
workspace.reconcile({
|
|
12313
|
-
activeSessionIds: external.listSessions().map((session) => session.sessionId),
|
|
12836
|
+
activeSessionIds: external.listSessions().filter((session) => session.control === "full").map((session) => session.sessionId),
|
|
12314
12837
|
validReservationIds: delegationStore.list().filter((record) => record.status === "starting").map((record) => record.delegationId)
|
|
12315
12838
|
});
|
|
12316
12839
|
external.reconcileWorkspace();
|
|
@@ -12332,7 +12855,7 @@ async function startDaemon(options) {
|
|
|
12332
12855
|
server.attachExternal(external, hookSecret);
|
|
12333
12856
|
const { port } = await server.listen();
|
|
12334
12857
|
writeFileSync2(
|
|
12335
|
-
|
|
12858
|
+
join7(dataDir2, "hook-endpoint.json"),
|
|
12336
12859
|
JSON.stringify({ url: `http://${options.host}:${port}/hook`, secret: hookSecret }, null, 2) + "\n",
|
|
12337
12860
|
{ mode: 384 }
|
|
12338
12861
|
);
|
|
@@ -12355,6 +12878,7 @@ async function startDaemon(options) {
|
|
|
12355
12878
|
posture: readPermissionPosture(),
|
|
12356
12879
|
stop: async () => {
|
|
12357
12880
|
stopMaintenance();
|
|
12881
|
+
codexWatcher.stop();
|
|
12358
12882
|
bridge?.stop();
|
|
12359
12883
|
await sessions.shutdown();
|
|
12360
12884
|
awake.release();
|
|
@@ -12372,9 +12896,9 @@ async function startDaemon(options) {
|
|
|
12372
12896
|
|
|
12373
12897
|
// ../daemon/src/config.ts
|
|
12374
12898
|
import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
12375
|
-
import { dirname as dirname3, join as
|
|
12899
|
+
import { dirname as dirname3, join as join8 } from "node:path";
|
|
12376
12900
|
function resolveRelayUrl(given, dataDir2) {
|
|
12377
|
-
const path =
|
|
12901
|
+
const path = join8(dataDir2, "config.json");
|
|
12378
12902
|
if (given !== void 0 && given.trim().toLowerCase() === "off") {
|
|
12379
12903
|
const config = load(path);
|
|
12380
12904
|
delete config.relayUrl;
|
|
@@ -12636,19 +13160,115 @@ function noAddressReason(interfaces = networkInterfaces()) {
|
|
|
12636
13160
|
return `No address a phone could dial (found: ${addresses.join(", ")}).`;
|
|
12637
13161
|
}
|
|
12638
13162
|
|
|
13163
|
+
// ../daemon/src/instance-lock.ts
|
|
13164
|
+
import { closeSync as closeSync3, lstatSync, mkdirSync as mkdirSync5, openSync as openSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
|
|
13165
|
+
import { randomUUID } from "node:crypto";
|
|
13166
|
+
import { join as join9 } from "node:path";
|
|
13167
|
+
var LOCK_NAME = "daemon.lock";
|
|
13168
|
+
var LOCK_KIND = "longleash-daemon";
|
|
13169
|
+
function acquireDaemonInstance(dataDir2) {
|
|
13170
|
+
mkdirSync5(dataDir2, { recursive: true, mode: 448 });
|
|
13171
|
+
const path = join9(dataDir2, LOCK_NAME);
|
|
13172
|
+
const token = randomUUID();
|
|
13173
|
+
const create = () => {
|
|
13174
|
+
const fd = openSync3(path, "wx", 384);
|
|
13175
|
+
try {
|
|
13176
|
+
writeFileSync4(fd, `${JSON.stringify({ kind: LOCK_KIND, pid: process.pid, token, startedAt: Date.now() })}
|
|
13177
|
+
`);
|
|
13178
|
+
} finally {
|
|
13179
|
+
closeSync3(fd);
|
|
13180
|
+
}
|
|
13181
|
+
};
|
|
13182
|
+
try {
|
|
13183
|
+
create();
|
|
13184
|
+
} catch (error) {
|
|
13185
|
+
if (error.code !== "EEXIST") throw error;
|
|
13186
|
+
if (lstatSync(path).isSymbolicLink()) throw new Error(`Refusing symlinked daemon lock: ${path}`);
|
|
13187
|
+
let owner = {};
|
|
13188
|
+
try {
|
|
13189
|
+
owner = JSON.parse(readFileSync5(path, "utf8"));
|
|
13190
|
+
} catch {
|
|
13191
|
+
}
|
|
13192
|
+
if (owner.kind !== LOCK_KIND || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
|
|
13193
|
+
throw new Error(`Unrecognized daemon lock requires manual inspection: ${path}`);
|
|
13194
|
+
}
|
|
13195
|
+
if (processAlive(owner.pid)) {
|
|
13196
|
+
throw new Error(`LongLeash already owns this data directory in process ${owner.pid}. Use \`longleash service status\` before starting another instance.`);
|
|
13197
|
+
}
|
|
13198
|
+
rmSync(path);
|
|
13199
|
+
create();
|
|
13200
|
+
}
|
|
13201
|
+
let held = true;
|
|
13202
|
+
return {
|
|
13203
|
+
path,
|
|
13204
|
+
release() {
|
|
13205
|
+
if (!held) return;
|
|
13206
|
+
held = false;
|
|
13207
|
+
try {
|
|
13208
|
+
const owner = JSON.parse(readFileSync5(path, "utf8"));
|
|
13209
|
+
if (owner.token === token) rmSync(path);
|
|
13210
|
+
} catch {
|
|
13211
|
+
}
|
|
13212
|
+
}
|
|
13213
|
+
};
|
|
13214
|
+
}
|
|
13215
|
+
function processAlive(pid) {
|
|
13216
|
+
try {
|
|
13217
|
+
process.kill(pid, 0);
|
|
13218
|
+
return true;
|
|
13219
|
+
} catch (error) {
|
|
13220
|
+
return error.code === "EPERM";
|
|
13221
|
+
}
|
|
13222
|
+
}
|
|
13223
|
+
|
|
13224
|
+
// ../daemon/src/service-log.ts
|
|
13225
|
+
function persistentServiceLogLine(line) {
|
|
13226
|
+
if (/codex\s+(?:<-|->)/i.test(line)) return null;
|
|
13227
|
+
const stamp = line.match(/^\[[^\]]+\]\s*/)?.[0] ?? "";
|
|
13228
|
+
const message = line.slice(stamp.length);
|
|
13229
|
+
const started2 = message.match(/^(claude|codex) session ([A-Za-z0-9_-]{1,32}) started in .+ \((terminal|vscode|phone)\)$/i);
|
|
13230
|
+
if (started2) return `${stamp}${started2[1].toLowerCase()} session ${started2[2]} started (${started2[3].toLowerCase()})`;
|
|
13231
|
+
if (/^\? .+ in [A-Za-z0-9_-]+ .* -> /i.test(message)) return `${stamp}tool decision recorded`;
|
|
13232
|
+
if (/^(decision|gate|resume|takeOver|stop|session)\s/i.test(message)) return `${stamp}session lifecycle state changed`;
|
|
13233
|
+
if (/^(delegation|start) refused:/i.test(message)) return `${stamp}session request refused; details are available on the paired device`;
|
|
13234
|
+
if (/^(relay|relay link|relay room|dropped a relay frame)/i.test(message)) return `${stamp}relay connection state changed`;
|
|
13235
|
+
if (/^(device|dropping unresponsive connection)/i.test(message)) return `${stamp}device connection state changed`;
|
|
13236
|
+
if (/^push:/i.test(message) || /^push (subscription|test)/i.test(message)) return `${stamp}push delivery state changed`;
|
|
13237
|
+
if (/^(keeping this Mac awake|all agents idle)/i.test(message)) return `${stamp}${message}`;
|
|
13238
|
+
if (/^rebound to /i.test(message)) return `${stamp}local listener rebound`;
|
|
13239
|
+
return `${stamp}service activity recorded; details omitted`;
|
|
13240
|
+
}
|
|
13241
|
+
|
|
12639
13242
|
// ../daemon/bin/longleashd.ts
|
|
12640
13243
|
var here = dirname4(fileURLToPath(import.meta.url));
|
|
12641
|
-
var APP_DIR =
|
|
13244
|
+
var APP_DIR = resolve6(here, "../../app/dist");
|
|
13245
|
+
var serviceMode = process.env.LONGLEASH_SERVICE === "1";
|
|
13246
|
+
if (serviceMode) {
|
|
13247
|
+
const fatal = () => {
|
|
13248
|
+
console.error("LongLeash service stopped unexpectedly; details omitted. Run `longleash doctor`.");
|
|
13249
|
+
process.exit(1);
|
|
13250
|
+
};
|
|
13251
|
+
process.on("uncaughtException", fatal);
|
|
13252
|
+
process.on("unhandledRejection", fatal);
|
|
13253
|
+
}
|
|
13254
|
+
function daemonLog(line) {
|
|
13255
|
+
if (!serviceMode) {
|
|
13256
|
+
console.log(line);
|
|
13257
|
+
return;
|
|
13258
|
+
}
|
|
13259
|
+
const safe = persistentServiceLogLine(line);
|
|
13260
|
+
if (safe !== null) console.log(safe);
|
|
13261
|
+
}
|
|
12642
13262
|
var roots = process.argv.slice(2).filter((arg) => !arg.startsWith("-"));
|
|
12643
13263
|
if (roots.length === 0) {
|
|
12644
13264
|
console.error("\nUsage: longleashd <project-directory> [more directories\u2026]");
|
|
12645
13265
|
console.error("Agents may only ever work inside the directories you list.\n");
|
|
12646
13266
|
process.exit(1);
|
|
12647
13267
|
}
|
|
12648
|
-
var dataDir = process.env.LONGLEASH_DATA ??
|
|
13268
|
+
var dataDir = process.env.LONGLEASH_DATA ?? join10(homedir5(), ".longleash");
|
|
12649
13269
|
var relay = resolveRelayUrl(process.env.LONGLEASH_RELAY_URL, dataDir);
|
|
12650
13270
|
if (relay?.source === "remembered") {
|
|
12651
|
-
console.log(`Relay: ${relay.url} (remembered \u2014 LONGLEASH_RELAY_URL=off forgets it)`);
|
|
13271
|
+
console.log(serviceMode ? "Relay configuration loaded." : `Relay: ${relay.url} (remembered \u2014 LONGLEASH_RELAY_URL=off forgets it)`);
|
|
12652
13272
|
}
|
|
12653
13273
|
var candidates = findCandidates();
|
|
12654
13274
|
var best = candidates[0];
|
|
@@ -12698,15 +13318,17 @@ async function boot(host, port) {
|
|
|
12698
13318
|
allowedRoots: roots,
|
|
12699
13319
|
host,
|
|
12700
13320
|
port,
|
|
12701
|
-
...existsSync7(
|
|
13321
|
+
...existsSync7(join10(APP_DIR, "index.html")) ? { staticRoot: APP_DIR } : {},
|
|
12702
13322
|
denyOutsideRoot: process.env.LONGLEASH_STRICT !== "0",
|
|
12703
13323
|
// LONGLEASH_ASK_EVERYTHING=1 pre-approves nothing, so even reading a file comes to your phone.
|
|
12704
13324
|
...process.env.LONGLEASH_ASK_EVERYTHING === "1" ? { allowedTools: [] } : {},
|
|
12705
13325
|
dataDir,
|
|
12706
|
-
log:
|
|
13326
|
+
log: daemonLog,
|
|
12707
13327
|
...relay === null ? {} : { relayUrl: relay.url }
|
|
12708
13328
|
});
|
|
12709
13329
|
}
|
|
13330
|
+
var instanceLock = acquireDaemonInstance(dataDir);
|
|
13331
|
+
process.once("exit", () => instanceLock.release());
|
|
12710
13332
|
var started = await (async () => {
|
|
12711
13333
|
const firstPort = holder === "other" ? 0 : wantedPort;
|
|
12712
13334
|
try {
|
|
@@ -12738,7 +13360,7 @@ function relayAppOrigin(wsUrl) {
|
|
|
12738
13360
|
function freshPairingUrl() {
|
|
12739
13361
|
const challenge = daemon.registry.createPairingChallenge();
|
|
12740
13362
|
if (relay !== null) {
|
|
12741
|
-
hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge, log: (line) =>
|
|
13363
|
+
hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge, log: (line) => daemonLog(`[pair] ${line}`) });
|
|
12742
13364
|
return pairingUrl(relayAppOrigin(relay.url), challenge.challengeId, challenge.secret);
|
|
12743
13365
|
}
|
|
12744
13366
|
return pairingUrl(
|
|
@@ -12747,15 +13369,16 @@ function freshPairingUrl() {
|
|
|
12747
13369
|
challenge.secret
|
|
12748
13370
|
);
|
|
12749
13371
|
}
|
|
12750
|
-
|
|
13372
|
+
daemon.server.attachLocalPairing(freshPairingUrl);
|
|
13373
|
+
var url = serviceMode ? null : freshPairingUrl();
|
|
12751
13374
|
console.log("\n=== LongLeash ===\n");
|
|
12752
13375
|
var warn = vpnWarning();
|
|
12753
13376
|
if (warn) console.log(`!!! ${warn}
|
|
12754
13377
|
`);
|
|
12755
|
-
if (!existsSync7(
|
|
13378
|
+
if (!existsSync7(join10(APP_DIR, "index.html"))) {
|
|
12756
13379
|
console.log("The web app is not built yet \u2014 run `pnpm --filter @longleash/app build` first.\n");
|
|
12757
13380
|
}
|
|
12758
|
-
if (daemon.posture.gateWeakened) {
|
|
13381
|
+
if (daemon.posture.gateWeakened && !serviceMode) {
|
|
12759
13382
|
console.log(`Note: your Claude Code settings pre-approve ${daemon.posture.allowRuleCount} pattern(s),`);
|
|
12760
13383
|
console.log("e.g. " + daemon.posture.examples.join(", "));
|
|
12761
13384
|
console.log("Those commands run WITHOUT asking your phone. They still appear in the activity");
|
|
@@ -12776,23 +13399,29 @@ if (preApproved.length > 0) {
|
|
|
12776
13399
|
} else {
|
|
12777
13400
|
console.log("");
|
|
12778
13401
|
}
|
|
12779
|
-
|
|
12780
|
-
|
|
12781
|
-
console.log(
|
|
12782
|
-
|
|
13402
|
+
if (serviceMode) {
|
|
13403
|
+
console.log(`Background service ready with ${roots.length} allowed project root(s).`);
|
|
13404
|
+
console.log(`Connectivity: ${relay !== null ? "encrypted relay enabled" : "LAN only"}.`);
|
|
13405
|
+
console.log("Pairing secrets are never written to service logs. Run `longleash pair` in a terminal.\n");
|
|
13406
|
+
} else {
|
|
13407
|
+
console.log("Agents may work only in:");
|
|
13408
|
+
for (const root of roots) console.log(` ${resolve6(root)}`);
|
|
13409
|
+
console.log(
|
|
13410
|
+
servedOnLan && best ? `
|
|
12783
13411
|
Listening on ${servedHost}:${daemon.port} (${best.iface}, ${best.label})` : `
|
|
12784
13412
|
Listening on ${servedHost}:${daemon.port} (no local network \u2014 relay only)`
|
|
12785
|
-
);
|
|
12786
|
-
console.log(
|
|
12787
|
-
|
|
12788
|
-
);
|
|
12789
|
-
console.log("\nScan this with your phone, then add it to your home screen:\n");
|
|
12790
|
-
console.log(terminalQr(url));
|
|
12791
|
-
console.log(`
|
|
13413
|
+
);
|
|
13414
|
+
console.log(
|
|
13415
|
+
relay !== null ? `Relay: ${relay.url} \u2014 your phone can reach this laptop from anywhere.` : "Relay: not configured (LAN only). Set LONGLEASH_RELAY_URL once to enable remote access \u2014 it is remembered."
|
|
13416
|
+
);
|
|
13417
|
+
console.log("\nScan this with your phone, then add it to your home screen:\n");
|
|
13418
|
+
console.log(terminalQr(url));
|
|
13419
|
+
console.log(`
|
|
12792
13420
|
${url}
|
|
12793
13421
|
`);
|
|
12794
|
-
if (relay !== null && servedOnLan) {
|
|
12795
|
-
|
|
13422
|
+
if (relay !== null && servedOnLan) {
|
|
13423
|
+
console.log(`(LAN fallback for pairing at home: http://${servedHost}:${daemon.port}/#c=\u2026&s=\u2026 \u2014 same code)`);
|
|
13424
|
+
}
|
|
12796
13425
|
}
|
|
12797
13426
|
var NETWORK_WATCH_MS = 5e3;
|
|
12798
13427
|
var networkWatch = setInterval(() => {
|
|
@@ -12803,23 +13432,23 @@ var networkWatch = setInterval(() => {
|
|
|
12803
13432
|
servingHost = nextHost;
|
|
12804
13433
|
void daemon.server.rebind(nextHost).then((port) => {
|
|
12805
13434
|
servingPort = port;
|
|
12806
|
-
console.log(`
|
|
13435
|
+
console.log(serviceMode ? "\nNetwork changed; the local listener recovered." : `
|
|
12807
13436
|
Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
|
|
12808
13437
|
console.log(
|
|
12809
13438
|
nextBest ? "Press n + Enter for a pairing QR on this network." : "No local network now \u2014 the relay carries everything."
|
|
12810
13439
|
);
|
|
12811
13440
|
}).catch((err) => {
|
|
12812
|
-
console.log(`
|
|
13441
|
+
console.log(serviceMode ? "\nNetwork changed but local listener recovery failed; details omitted." : `
|
|
12813
13442
|
Network changed but rebinding failed (${String(err)}).`);
|
|
12814
13443
|
console.log(relay !== null ? "The relay is unaffected." : "Restart the daemon to recover.");
|
|
12815
13444
|
});
|
|
12816
13445
|
}, NETWORK_WATCH_MS);
|
|
12817
13446
|
networkWatch.unref?.();
|
|
12818
|
-
console.log("Press n + Enter for a fresh pairing QR, r + Enter to revoke every device, q + Enter to quit.\n");
|
|
13447
|
+
if (!serviceMode) console.log("Press n + Enter for a fresh pairing QR, r + Enter to revoke every device, q + Enter to quit.\n");
|
|
12819
13448
|
process.stdin.setEncoding("utf8");
|
|
12820
13449
|
process.stdin.on("data", (chunk) => {
|
|
12821
13450
|
const key = chunk.trim().toLowerCase();
|
|
12822
|
-
if (key === "n") {
|
|
13451
|
+
if (key === "n" && !serviceMode) {
|
|
12823
13452
|
const next = freshPairingUrl();
|
|
12824
13453
|
console.log("\nScan this with your phone:\n");
|
|
12825
13454
|
console.log(terminalQr(next));
|
|
@@ -12834,12 +13463,18 @@ process.stdin.on("data", (chunk) => {
|
|
|
12834
13463
|
}
|
|
12835
13464
|
if (key === "q") {
|
|
12836
13465
|
clearInterval(networkWatch);
|
|
12837
|
-
void daemon.stop().then(() =>
|
|
13466
|
+
void daemon.stop().then(() => {
|
|
13467
|
+
instanceLock.release();
|
|
13468
|
+
process.exit(0);
|
|
13469
|
+
});
|
|
12838
13470
|
}
|
|
12839
13471
|
});
|
|
12840
13472
|
var shutdown = () => {
|
|
12841
13473
|
clearInterval(networkWatch);
|
|
12842
|
-
void daemon.stop().then(() =>
|
|
13474
|
+
void daemon.stop().then(() => {
|
|
13475
|
+
instanceLock.release();
|
|
13476
|
+
process.exit(0);
|
|
13477
|
+
});
|
|
12843
13478
|
};
|
|
12844
13479
|
process.on("SIGINT", shutdown);
|
|
12845
13480
|
process.on("SIGTERM", shutdown);
|