@saadnvd1/agent-os 0.3.775 → 0.3.778
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/app/api/live-shares/[id]/route.ts +72 -0
- package/app/api/live-shares/route.test.ts +45 -0
- package/app/api/live-shares/route.ts +40 -0
- package/app/page.tsx +5 -0
- package/components/Chat/ChatPanel.tsx +6 -0
- package/components/Chat/EventRow.tsx +6 -0
- package/components/Live/GuestPromptCard.tsx +64 -0
- package/components/Live/InviteDialog.tsx +225 -0
- package/components/Live/LiveChip.tsx +109 -0
- package/components/Live/LivePanel.tsx +81 -0
- package/components/Live/LobbyCard.tsx +64 -0
- package/components/Pane/MobileTabBar.tsx +8 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/Shares/SharedLinksPanel.tsx +51 -0
- package/data/live/index.ts +2 -0
- package/data/live/keys.ts +4 -0
- package/data/live/queries.ts +81 -0
- package/lib/chat/events.ts +4 -1
- package/lib/chat/registry.ts +10 -0
- package/lib/connect/control-api.ts +44 -0
- package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
- package/lib/live/clean.ts +54 -0
- package/lib/live/frames.ts +132 -0
- package/lib/live/inbox.ts +225 -0
- package/lib/live/publisher.ts +84 -0
- package/lib/live/ring.ts +61 -0
- package/lib/live/runtime.ts +51 -0
- package/lib/live/service.ts +318 -0
- package/lib/live/socket.ts +293 -0
- package/lib/live/store.ts +149 -0
- package/lib/live/types.ts +39 -0
- package/lib/orchestrator/describe.ts +26 -2
- package/lib/orchestrator/facts.ts +4 -0
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/program-status/store.ts +2 -1
- package/lib/session-activity.ts +5 -3
- package/lib/share/connect.ts +24 -0
- package/lib/share/redact.ts +23 -1
- package/lib/share/secret-patterns.ts +77 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/lib/sidebar/shelves.ts +4 -1
- package/lib/status/collect.ts +2 -1
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/liveUi.ts +17 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
|
|
3
|
+
export interface LiveShareRow {
|
|
4
|
+
id: string;
|
|
5
|
+
session_id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
ends_with_session: number;
|
|
9
|
+
max_guests: number;
|
|
10
|
+
created_at: string;
|
|
11
|
+
expires_at: string | null;
|
|
12
|
+
ended_at: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type LogKind =
|
|
16
|
+
| "join_request"
|
|
17
|
+
| "admit"
|
|
18
|
+
| "deny"
|
|
19
|
+
| "kick"
|
|
20
|
+
| "suggestion"
|
|
21
|
+
| "sent"
|
|
22
|
+
| "dismissed";
|
|
23
|
+
|
|
24
|
+
export interface LogRow {
|
|
25
|
+
id: number;
|
|
26
|
+
share_id: string;
|
|
27
|
+
at: number;
|
|
28
|
+
kind: LogKind;
|
|
29
|
+
guest_id: string | null;
|
|
30
|
+
guest_name: string | null;
|
|
31
|
+
ref: string | null;
|
|
32
|
+
text: string | null;
|
|
33
|
+
sent_text: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function insertLiveShare(row: LiveShareRow): void {
|
|
37
|
+
db.prepare(
|
|
38
|
+
`INSERT INTO live_shares
|
|
39
|
+
(id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at)
|
|
40
|
+
VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at)`
|
|
41
|
+
).run(row);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getLiveShare(id: string): LiveShareRow | undefined {
|
|
45
|
+
return db.prepare(`SELECT * FROM live_shares WHERE id = ?`).get(id) as
|
|
46
|
+
LiveShareRow | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Shares this machine still holds: not ended, not past their time. */
|
|
50
|
+
export function activeLiveShares(now = Date.now()): LiveShareRow[] {
|
|
51
|
+
return (
|
|
52
|
+
db
|
|
53
|
+
.prepare(
|
|
54
|
+
`SELECT * FROM live_shares WHERE ended_at IS NULL ORDER BY created_at, rowid`
|
|
55
|
+
)
|
|
56
|
+
.all() as LiveShareRow[]
|
|
57
|
+
).filter((s) => !s.expires_at || Date.parse(s.expires_at) > now);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function activeLiveShareFor(
|
|
61
|
+
sessionId: string,
|
|
62
|
+
now = Date.now()
|
|
63
|
+
): LiveShareRow | undefined {
|
|
64
|
+
return activeLiveShares(now).find((s) => s.session_id === sessionId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function markLiveEnded(id: string): void {
|
|
68
|
+
db.prepare(
|
|
69
|
+
`UPDATE live_shares SET ended_at = COALESCE(ended_at, ?) WHERE id = ?`
|
|
70
|
+
).run(new Date().toISOString(), id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function logLive(
|
|
74
|
+
row: Omit<LogRow, "id" | "at"> & { at?: number }
|
|
75
|
+
): void {
|
|
76
|
+
db.prepare(
|
|
77
|
+
`INSERT INTO live_share_log (share_id, at, kind, guest_id, guest_name, ref, text, sent_text)
|
|
78
|
+
VALUES (@share_id, @at, @kind, @guest_id, @guest_name, @ref, @text, @sent_text)`
|
|
79
|
+
).run({ ...row, at: row.at ?? Date.now() });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function listLog(shareId: string): LogRow[] {
|
|
83
|
+
return db
|
|
84
|
+
.prepare(`SELECT * FROM live_share_log WHERE share_id = ? ORDER BY id`)
|
|
85
|
+
.all(shareId) as LogRow[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface OpenSuggestion {
|
|
89
|
+
id: string;
|
|
90
|
+
guestId: string;
|
|
91
|
+
guestName: string;
|
|
92
|
+
text: string;
|
|
93
|
+
at: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Suggestions with no sent or dismissed line yet, oldest first. */
|
|
97
|
+
export function openSuggestions(shareId: string): OpenSuggestion[] {
|
|
98
|
+
const rows = db
|
|
99
|
+
.prepare(
|
|
100
|
+
`SELECT s.ref, s.guest_id, s.guest_name, s.text, s.at FROM live_share_log s
|
|
101
|
+
WHERE s.share_id = ? AND s.kind = 'suggestion' AND NOT EXISTS (
|
|
102
|
+
SELECT 1 FROM live_share_log d
|
|
103
|
+
WHERE d.share_id = s.share_id AND d.ref = s.ref AND d.kind IN ('sent', 'dismissed'))
|
|
104
|
+
ORDER BY s.id`
|
|
105
|
+
)
|
|
106
|
+
.all(shareId) as {
|
|
107
|
+
ref: string;
|
|
108
|
+
guest_id: string;
|
|
109
|
+
guest_name: string;
|
|
110
|
+
text: string;
|
|
111
|
+
at: number;
|
|
112
|
+
}[];
|
|
113
|
+
return rows.map((r) => ({
|
|
114
|
+
id: r.ref,
|
|
115
|
+
guestId: r.guest_id,
|
|
116
|
+
guestName: r.guest_name,
|
|
117
|
+
text: r.text,
|
|
118
|
+
at: r.at,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Guests this machine admitted and has not removed since. */
|
|
123
|
+
export function admittedGuestIds(shareId: string): Set<string> {
|
|
124
|
+
const out = new Set<string>();
|
|
125
|
+
for (const l of listLog(shareId)) {
|
|
126
|
+
if (!l.guest_id) continue;
|
|
127
|
+
if (l.kind === "admit") out.add(l.guest_id);
|
|
128
|
+
else if (l.kind === "kick" || l.kind === "deny") out.delete(l.guest_id);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a suggestion with this id was ever logged for the share. */
|
|
134
|
+
export function hasSuggestion(shareId: string, ref: string): boolean {
|
|
135
|
+
return !!db
|
|
136
|
+
.prepare(
|
|
137
|
+
`SELECT 1 FROM live_share_log WHERE share_id = ? AND kind = 'suggestion' AND ref = ? LIMIT 1`
|
|
138
|
+
)
|
|
139
|
+
.get(shareId, ref);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether a join request from this guest was already logged. */
|
|
143
|
+
export function hasJoinRequest(shareId: string, guestId: string): boolean {
|
|
144
|
+
return !!db
|
|
145
|
+
.prepare(
|
|
146
|
+
`SELECT 1 FROM live_share_log WHERE share_id = ? AND kind = 'join_request' AND guest_id = ? LIMIT 1`
|
|
147
|
+
)
|
|
148
|
+
.get(shareId, guestId);
|
|
149
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// What the host's screens show of a live share (served by /api/live-shares).
|
|
2
|
+
export interface LiveView {
|
|
3
|
+
id: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
title: string;
|
|
6
|
+
url: string;
|
|
7
|
+
expiresAt: string | null;
|
|
8
|
+
endsWithSession: boolean;
|
|
9
|
+
maxGuests: number;
|
|
10
|
+
connected: boolean;
|
|
11
|
+
notice: string | null;
|
|
12
|
+
guests: {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
status: "pending" | "admitted";
|
|
16
|
+
online: boolean;
|
|
17
|
+
/** Another guest here has the same name. */
|
|
18
|
+
duplicateName: boolean;
|
|
19
|
+
}[];
|
|
20
|
+
suggestions: {
|
|
21
|
+
id: string;
|
|
22
|
+
guestId: string;
|
|
23
|
+
guestName: string;
|
|
24
|
+
text: string;
|
|
25
|
+
at: number;
|
|
26
|
+
}[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type LiveExpiry = "1h" | "session";
|
|
30
|
+
|
|
31
|
+
export const EXPIRY_LABEL: Record<LiveExpiry, string> = {
|
|
32
|
+
"1h": "1 hour",
|
|
33
|
+
session: "Until the session ends",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface LiveResponse {
|
|
37
|
+
linked: boolean;
|
|
38
|
+
shares: LiveView[];
|
|
39
|
+
}
|
|
@@ -64,15 +64,35 @@ function sessionLine(f: SessionFacts): string {
|
|
|
64
64
|
return parts.join(" | ");
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
const compactLine = (f: SessionFacts) =>
|
|
68
|
+
`${f.name} (${f.project ?? "no project"}, id ${shortId(f.id)})`;
|
|
69
|
+
|
|
70
|
+
// Nothing to say about it beyond that it is idle.
|
|
71
|
+
const plainIdle = (f: SessionFacts) =>
|
|
72
|
+
f.status === "idle" &&
|
|
73
|
+
!f.activity &&
|
|
74
|
+
!f.task &&
|
|
75
|
+
!f.plan &&
|
|
76
|
+
!f.group &&
|
|
77
|
+
!f.stack &&
|
|
78
|
+
!f.compacting &&
|
|
79
|
+
!f.waits?.on.length &&
|
|
80
|
+
!f.waits?.by.length;
|
|
81
|
+
|
|
82
|
+
// The sessions tool's answer: one line per live session, busiest news first;
|
|
83
|
+
// then the done and plain idle ones, compactly, so a done can be seen to have
|
|
84
|
+
// worked.
|
|
68
85
|
export function describeSessions(
|
|
69
86
|
workspace: string,
|
|
70
87
|
facts: SessionFacts[],
|
|
71
88
|
groups: readonly string[] = []
|
|
72
89
|
): string {
|
|
73
90
|
if (!facts.length) return `No sessions are running in ${workspace}.`;
|
|
91
|
+
const done = facts.filter((f) => f.done && f.status !== "running");
|
|
92
|
+
const idle = facts.filter((f) => !f.done && plainIdle(f));
|
|
93
|
+
const shown = facts.filter((f) => !done.includes(f) && !idle.includes(f));
|
|
74
94
|
const rank = { waiting: 0, running: 1, idle: 2, dead: 3 };
|
|
75
|
-
const sorted = [...
|
|
95
|
+
const sorted = [...shown].sort((a, b) => rank[a.status] - rank[b.status]);
|
|
76
96
|
const counts = (["running", "waiting"] as const)
|
|
77
97
|
.map((s) => {
|
|
78
98
|
const n = facts.filter((f) => f.status === s).length;
|
|
@@ -84,6 +104,10 @@ export function describeSessions(
|
|
|
84
104
|
return [
|
|
85
105
|
`${facts.length} ${noun} in ${workspace}${summary}:`,
|
|
86
106
|
...sorted.map(sessionLine),
|
|
107
|
+
...(idle.length ? [`Idle: ${idle.map(compactLine).join("; ")}.`] : []),
|
|
108
|
+
...(done.length
|
|
109
|
+
? [`Done (on Saad's Done shelf): ${done.map(compactLine).join("; ")}.`]
|
|
110
|
+
: []),
|
|
87
111
|
...(groups.length
|
|
88
112
|
? [`Saad's groups here: ${groups.map((g) => `"${g}"`).join(", ")}.`]
|
|
89
113
|
: []),
|
|
@@ -27,6 +27,7 @@ import { commitTime, repoOf } from "./repo";
|
|
|
27
27
|
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
28
28
|
import { repoHasCI } from "./repo-ci";
|
|
29
29
|
import { markStatusDirty } from "../status/dirty";
|
|
30
|
+
import { settledHow } from "../sidebar/shelves";
|
|
30
31
|
|
|
31
32
|
export type FactStatus = "running" | "waiting" | "idle" | "dead";
|
|
32
33
|
|
|
@@ -46,6 +47,8 @@ export interface SessionFacts {
|
|
|
46
47
|
compacting?: boolean;
|
|
47
48
|
// Saad's group it sits in, when it is in one.
|
|
48
49
|
group?: string | null;
|
|
50
|
+
// On Saad's Done shelf: marked done (or quiet for days) and not woken since.
|
|
51
|
+
done?: boolean;
|
|
49
52
|
// Last sign of life, in ms.
|
|
50
53
|
lastActive: number;
|
|
51
54
|
// Open waits (aos send --await, aos wait): who it waits on, and who waits on it.
|
|
@@ -346,6 +349,7 @@ export async function sessionFacts(
|
|
|
346
349
|
compacting:
|
|
347
350
|
s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
|
|
348
351
|
group: groups.get(s.session_group_id ?? "") ?? null,
|
|
352
|
+
done: settledHow(s, Date.now()) !== null,
|
|
349
353
|
lastActive: await lastActiveFor(s),
|
|
350
354
|
branch: s.branch_name,
|
|
351
355
|
quick: !!s.quick_of,
|
|
@@ -116,7 +116,7 @@ const answer = async (call: Promise<string>): Promise<CallToolResult> => {
|
|
|
116
116
|
|
|
117
117
|
const DESCRIPTIONS: Record<ToolName, string> = {
|
|
118
118
|
sessions:
|
|
119
|
-
"Every session in this workspace: project, view, status, what it's doing, task/PR/CI state and stack position; then external sessions (tmux sessions AgentOS didn't start, here or on other machines, working in a workspace repository), read-only, with their branch's PR.",
|
|
119
|
+
"Every session in this workspace: project, view, status, what it's doing, task/PR/CI state and stack position; plain idle and done sessions (on Saad's Done shelf) are listed compactly after them; then external sessions (tmux sessions AgentOS didn't start, here or on other machines, working in a workspace repository), read-only, with their branch's PR.",
|
|
120
120
|
read: "The end of one session's terminal or chat, or an external session's screen, as plain text (capped at about 4k tokens).",
|
|
121
121
|
cards: "Cards on this workspace's linked LumifyHub boards, by list.",
|
|
122
122
|
logs: "Session logs AgentOS wrote when tasks merged: the last N headers (task, PR, cost, duration) of the workspace or one project, or one log in full (brief, plan, timeline, usage, outcome). Read straight from the database; nothing is summarised.",
|
|
@@ -85,7 +85,8 @@ function commit(sessionName: string, next: Records): boolean {
|
|
|
85
85
|
if (same(before, after)) return false;
|
|
86
86
|
if (after && after.state !== "idle" && after.state !== before?.state)
|
|
87
87
|
db.prepare(
|
|
88
|
-
`UPDATE sessions SET updated_at = datetime('now'), active_at = datetime('now') WHERE tmux_name = ? OR id =
|
|
88
|
+
`UPDATE sessions SET updated_at = datetime('now'), active_at = datetime('now') WHERE (tmux_name = ? OR id = ?)
|
|
89
|
+
AND (settled_at IS NULL OR active_at > settled_at)`
|
|
89
90
|
).run(sessionName, getSessionIdFromName(sessionName));
|
|
90
91
|
return true;
|
|
91
92
|
}
|
package/lib/session-activity.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { getDb } from "./db";
|
|
2
2
|
|
|
3
3
|
// A session did something: a message, a run starting, a need. Written at most
|
|
4
|
-
// once a minute, unless it wakes a snooze
|
|
4
|
+
// once a minute, unless it wakes a snooze. Never for a settled session: what
|
|
5
|
+
// comes after Done (the stop's own events, a status refresh) isn't the work
|
|
6
|
+
// starting again. Only a prompt does that (the chat worker writes it).
|
|
5
7
|
export function touchActive(sessionId: string): void {
|
|
6
8
|
try {
|
|
7
9
|
getDb()
|
|
@@ -9,8 +11,8 @@ export function touchActive(sessionId: string): void {
|
|
|
9
11
|
`UPDATE sessions SET active_at = datetime('now')
|
|
10
12
|
WHERE id = ? AND (active_at IS NULL
|
|
11
13
|
OR active_at < datetime('now', '-60 seconds')
|
|
12
|
-
OR (snoozed_at IS NOT NULL AND active_at <= snoozed_at)
|
|
13
|
-
|
|
14
|
+
OR (snoozed_at IS NOT NULL AND active_at <= snoozed_at))
|
|
15
|
+
AND (settled_at IS NULL OR active_at > settled_at)`
|
|
14
16
|
)
|
|
15
17
|
.run(sessionId);
|
|
16
18
|
} catch (err) {
|
package/lib/share/connect.ts
CHANGED
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
clean,
|
|
7
7
|
listShares,
|
|
8
8
|
replaceShare,
|
|
9
|
+
createLiveShare,
|
|
9
10
|
revokeShare,
|
|
10
11
|
uploadShare,
|
|
12
|
+
type LiveExpiry,
|
|
11
13
|
type RemoteShare,
|
|
12
14
|
type ShareExpiry,
|
|
13
15
|
} from "@/lib/connect/control-api";
|
|
@@ -40,6 +42,16 @@ function explain(err: unknown): never {
|
|
|
40
42
|
`Connect refused it as too big or over your quota: ${clean(err.message, 120)}`,
|
|
41
43
|
413
|
|
42
44
|
);
|
|
45
|
+
if (err.status === 402)
|
|
46
|
+
throw new ShareError(
|
|
47
|
+
clean(
|
|
48
|
+
typeof err.body.error === "string"
|
|
49
|
+
? err.body.error
|
|
50
|
+
: "Your plan's live share limit is reached.",
|
|
51
|
+
160
|
|
52
|
+
),
|
|
53
|
+
402
|
|
54
|
+
);
|
|
43
55
|
if (err.status === 429)
|
|
44
56
|
throw new ShareError(
|
|
45
57
|
"Too many shares just now. Try again in a few minutes.",
|
|
@@ -95,3 +107,15 @@ export async function revokeRemote(id: string): Promise<void> {
|
|
|
95
107
|
explain(err);
|
|
96
108
|
}
|
|
97
109
|
}
|
|
110
|
+
|
|
111
|
+
export async function createLiveRemote(share: {
|
|
112
|
+
title: string;
|
|
113
|
+
expiresIn: LiveExpiry;
|
|
114
|
+
}) {
|
|
115
|
+
const { config, machineKey } = files();
|
|
116
|
+
try {
|
|
117
|
+
return await createLiveShare(config, machineKey, share);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
explain(err);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/lib/share/redact.ts
CHANGED
|
@@ -18,6 +18,15 @@ import {
|
|
|
18
18
|
BASE64_RUN,
|
|
19
19
|
looksRandom,
|
|
20
20
|
} from "@/lib/orchestrator/untrusted";
|
|
21
|
+
import {
|
|
22
|
+
BASIC_AUTH,
|
|
23
|
+
CLI_FLAG,
|
|
24
|
+
DATA_URI,
|
|
25
|
+
KEY_VALUE,
|
|
26
|
+
MYSQL_P,
|
|
27
|
+
TOKEN_RUN,
|
|
28
|
+
isHighEntropyToken,
|
|
29
|
+
} from "./secret-patterns";
|
|
21
30
|
import { HOME } from "@/lib/session-audit/normalize";
|
|
22
31
|
|
|
23
32
|
export type Kind = "secret" | "path" | "ip" | "host" | "email";
|
|
@@ -59,7 +68,8 @@ const SECRET = "[secret]";
|
|
|
59
68
|
const BEARER = /(\bbearer\s+)(?!\[secret\])[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
60
69
|
const INLINE_ASSIGNMENT =
|
|
61
70
|
/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*=)(?!\[secret\])[^\s'"]+/g;
|
|
62
|
-
|
|
71
|
+
// Up to 64 hex characters is a git sha or a hash, not a key.
|
|
72
|
+
const LONG_HEX = /\b[a-fA-F0-9]{65,}\b/g;
|
|
63
73
|
|
|
64
74
|
const IPV4 =
|
|
65
75
|
/(?<![\w.])(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?![\w.]*\w)/g;
|
|
@@ -104,6 +114,10 @@ export function redactForShare(
|
|
|
104
114
|
let t = input;
|
|
105
115
|
const on = (k: Kind) => !opts.only || opts.only.includes(k);
|
|
106
116
|
|
|
117
|
+
// Inline base64 (images) is content, not a credential: set aside, put back.
|
|
118
|
+
const uris: string[] = [];
|
|
119
|
+
t = t.replace(DATA_URI, (m) => `\u0000${uris.push(m) - 1}\u0000`);
|
|
120
|
+
|
|
107
121
|
// 1. Credentials by shape.
|
|
108
122
|
t = sub(t, PRIVATE_KEY_BLOCK, SECRET, hits, "secret");
|
|
109
123
|
t = sub(
|
|
@@ -132,9 +146,16 @@ export function redactForShare(
|
|
|
132
146
|
"secret",
|
|
133
147
|
(m) => m.endsWith(SECRET)
|
|
134
148
|
);
|
|
149
|
+
const keepValue = (_m: string, k: string, v: string) =>
|
|
150
|
+
`${k}${/^["']/.test(v) ? v[0] + SECRET + v[0] : SECRET}`;
|
|
151
|
+
t = sub(t, KEY_VALUE, keepValue, hits, "secret");
|
|
152
|
+
t = sub(t, CLI_FLAG, keepValue, hits, "secret");
|
|
153
|
+
t = sub(t, MYSQL_P, (_m, k) => `${k}${SECRET}`, hits, "secret");
|
|
154
|
+
t = sub(t, BASIC_AUTH, (_m, k) => `${k}${SECRET}`, hits, "secret");
|
|
135
155
|
for (const re of SECRET_TOKENS) t = sub(t, re, SECRET, hits, "secret");
|
|
136
156
|
t = sub(t, LONG_HEX, SECRET, hits, "secret");
|
|
137
157
|
t = sub(t, BASE64_RUN, SECRET, hits, "secret", (m) => !looksRandom(m));
|
|
158
|
+
t = sub(t, TOKEN_RUN, SECRET, hits, "secret", (m) => !isHighEntropyToken(m));
|
|
138
159
|
|
|
139
160
|
// 2. This machine's own secrets, wherever they appear.
|
|
140
161
|
for (const value of ctx.secrets) {
|
|
@@ -192,5 +213,6 @@ export function redactForShare(
|
|
|
192
213
|
// 6. Email addresses.
|
|
193
214
|
if (on("email")) t = sub(t, EMAIL, "[email]", hits, "email");
|
|
194
215
|
|
|
216
|
+
t = t.replace(/\u0000(\d+)\u0000/g, (_m, i) => uris[Number(i)]);
|
|
195
217
|
return { text: t, hits };
|
|
196
218
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic credential shapes for redactForShare: key/value secrets in any
|
|
3
|
+
* syntax, Basic auth headers, mysql -p, and high-entropy tokens. Pure data
|
|
4
|
+
* and predicates; redact.ts applies them and counts the hits.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const WORDS =
|
|
8
|
+
"password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|client[_-]?secret|auth|private[_-]?key";
|
|
9
|
+
|
|
10
|
+
// A value that is a type, a placeholder or a call, not a credential.
|
|
11
|
+
const NOT_A_VALUE =
|
|
12
|
+
"(?![\"']?\\[secret\\])" +
|
|
13
|
+
"(?!(?:string|number|boolean|any|unknown|null|undefined|true|false|none|void|required|optional)\\b)" +
|
|
14
|
+
"(?![$<{(\\[%]|\\.\\.\\.)(?![\\w.]+\\()";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `password=x`, `"api_key": "x"`, `token: x`, `export DB_PASSWORD=x`,
|
|
18
|
+
* `--password=x`. The key may carry any prefix (GITHUB_TOKEN) but must end
|
|
19
|
+
* in the word, so `author` and `max_tokens` stay. Group 1 is kept.
|
|
20
|
+
*/
|
|
21
|
+
export const KEY_VALUE = new RegExp(
|
|
22
|
+
`((?<![\\w.-])[\\w.-]*(?:${WORDS})["']?[ \\t]*[:=][ \\t]*)` +
|
|
23
|
+
`${NOT_A_VALUE}("[^"\\n]*"|'[^'\\n]*'|[^\\s,;'"&}\\])]+)`,
|
|
24
|
+
"gi"
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/** `--password value`, `--api-key value` (space separated). */
|
|
28
|
+
export const CLI_FLAG = new RegExp(
|
|
29
|
+
`(--[\\w-]*?(?:${WORDS})[ \\t]+)(?!-)${NOT_A_VALUE}("[^"\\n]*"|'[^'\\n]*'|\\S+)`,
|
|
30
|
+
"gi"
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* mysql-family `-pSECRET` (attached: `-p secret` would be a database name).
|
|
35
|
+
* psql's -p is a port, so it is left alone.
|
|
36
|
+
*/
|
|
37
|
+
export const MYSQL_P =
|
|
38
|
+
/(\b(?:mysql|mysqldump|mysqladmin|mariadb)\b[^\n|;&]*?\s-p)(?=\S)(\S+)/gi;
|
|
39
|
+
|
|
40
|
+
/** `Authorization: Basic <x>` (Bearer is handled in redact.ts). */
|
|
41
|
+
export const BASIC_AUTH =
|
|
42
|
+
/(\bauthorization["']?\s*[:=]\s*["']?basic\s+)[A-Za-z0-9+/=._~-]{4,}/gi;
|
|
43
|
+
|
|
44
|
+
/** A base64 data URI, such as an inline image. Kept as is: not a credential. */
|
|
45
|
+
export const DATA_URI =
|
|
46
|
+
/data:[\w.+-]+\/[\w.+-]+(?:;[\w=.-]+)*;base64,[A-Za-z0-9+/=_-]+/g;
|
|
47
|
+
|
|
48
|
+
/** Candidate runs for the entropy test. */
|
|
49
|
+
export const TOKEN_RUN = /(?<![A-Za-z0-9_\-+/=])[A-Za-z0-9_\-+/=]{32,}/g;
|
|
50
|
+
|
|
51
|
+
export const ENTROPY_MIN = 4.0;
|
|
52
|
+
|
|
53
|
+
export function shannon(s: string): number {
|
|
54
|
+
const counts = new Map<string, number>();
|
|
55
|
+
for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
56
|
+
let h = 0;
|
|
57
|
+
for (const n of counts.values())
|
|
58
|
+
h -= (n / s.length) * Math.log2(n / s.length);
|
|
59
|
+
return h;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
63
|
+
|
|
64
|
+
function looksLikePath(s: string): boolean {
|
|
65
|
+
if (/^(?:\.{0,2}\/|~\/)/.test(s)) return true;
|
|
66
|
+
const parts = s.split("/");
|
|
67
|
+
return parts.length > 2 && parts.every((p) => p.length >= 2);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when a run is a random-looking token rather than prose, a hash or a path. */
|
|
71
|
+
export function isHighEntropyToken(s: string): boolean {
|
|
72
|
+
if (/^[0-9a-f]+$/i.test(s) && s.length <= 64) return false; // git sha, md5/sha256
|
|
73
|
+
if (UUID.test(s)) return false;
|
|
74
|
+
if (looksLikePath(s)) return false;
|
|
75
|
+
if (!/[a-z]/.test(s) || !/[A-Z]/.test(s) || !/[0-9]/.test(s)) return false;
|
|
76
|
+
return shannon(s) > ENTROPY_MIN;
|
|
77
|
+
}
|
package/lib/sidebar/row-menu.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type RowMenuItem =
|
|
|
17
17
|
| "copy-link"
|
|
18
18
|
| "share"
|
|
19
19
|
| "export"
|
|
20
|
+
| "invite-live"
|
|
20
21
|
| "fork"
|
|
21
22
|
| "fresh-start"
|
|
22
23
|
| "new-conversation"
|
|
@@ -127,6 +128,7 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
|
|
|
127
128
|
...group("share", [
|
|
128
129
|
"copy-link",
|
|
129
130
|
s.view === "chat" && "share",
|
|
131
|
+
s.view === "chat" && "invite-live",
|
|
130
132
|
s.view === "chat" && "export",
|
|
131
133
|
]),
|
|
132
134
|
...group("restart", [
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -220,7 +220,10 @@ export function settledHow(
|
|
|
220
220
|
now: number
|
|
221
221
|
): "manual" | "auto" | null {
|
|
222
222
|
const active = activeMs(session, now);
|
|
223
|
-
|
|
223
|
+
// A settled session's own row is the clock: updated_at moves with
|
|
224
|
+
// bookkeeping, so it never takes a done session off the shelf.
|
|
225
|
+
const own = session.peer_mirror || session.active_at ? active : 0;
|
|
226
|
+
if (session.settled_at && own <= ms(session.settled_at)) return "manual";
|
|
224
227
|
return now - active >= SETTLE_AFTER_MS ? "auto" : null;
|
|
225
228
|
}
|
|
226
229
|
|
package/lib/status/collect.ts
CHANGED
|
@@ -421,7 +421,8 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
421
421
|
|
|
422
422
|
// Batch update sessions and claude_session_id in a single transaction
|
|
423
423
|
const updateStatusStmt = db.prepare(
|
|
424
|
-
|
|
424
|
+
`UPDATE sessions SET updated_at = datetime('now'), active_at = datetime('now')
|
|
425
|
+
WHERE id = ? AND (settled_at IS NULL OR active_at > settled_at)`
|
|
425
426
|
);
|
|
426
427
|
const updateClaudeIdStmt = db.prepare(
|
|
427
428
|
"UPDATE sessions SET claude_session_id = ? WHERE id = ? AND (claude_session_id IS NULL OR claude_session_id != ?)"
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
import { lanEnabled } from "./lib/security/network-settings";
|
|
55
55
|
import { handlePeerRpc, isPeerRpc, rpcDeps } from "./lib/peer/rpc-server";
|
|
56
56
|
import { startConnect } from "./lib/connect/serve";
|
|
57
|
+
import { startLive } from "./lib/live/service";
|
|
57
58
|
import { serverPort } from "./lib/connect/config";
|
|
58
59
|
import { startUpdateChecks, updateChecksEnabled } from "./lib/updates/check";
|
|
59
60
|
import {
|
|
@@ -399,6 +400,7 @@ app.prepare().then(async () => {
|
|
|
399
400
|
? { hostname: () => null }
|
|
400
401
|
: startConnect({ onRequest, onUpgrade }, port);
|
|
401
402
|
policy.machineHost = () => connect.hostname();
|
|
403
|
+
if (!demo) startLive();
|
|
402
404
|
// HTTPS on the tailnet (its own port) for passkeys and other secure-origin APIs.
|
|
403
405
|
if (
|
|
404
406
|
!demo &&
|
package/stores/liveUi.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { proxy } from "valtio";
|
|
2
|
+
|
|
3
|
+
// The invite dialog: which chat it is open on, started from a menu.
|
|
4
|
+
export const liveUi = proxy<{
|
|
5
|
+
target: { sessionId: string; title: string } | null;
|
|
6
|
+
}>({
|
|
7
|
+
target: null,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const liveUiActions = {
|
|
11
|
+
open: (sessionId: string, title: string) => {
|
|
12
|
+
liveUi.target = { sessionId, title };
|
|
13
|
+
},
|
|
14
|
+
close: () => {
|
|
15
|
+
liveUi.target = null;
|
|
16
|
+
},
|
|
17
|
+
};
|