Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebfae9b891 | |||
| 3ab484ddc0 | |||
| 6bcef26446 | |||
| 240444eafd | |||
| d09c9d6633 |
@@ -142,6 +142,11 @@
|
|||||||
"installsYou": "You",
|
"installsYou": "You",
|
||||||
"installsUnknown": "A removed user",
|
"installsUnknown": "A removed user",
|
||||||
"installsReplaced": "replaced an older copy",
|
"installsReplaced": "replaced an older copy",
|
||||||
|
"versionAt": "Updated {{ago}}",
|
||||||
|
"versionAtFull": "Last updated {{date}}",
|
||||||
|
"installState.latest": "Latest",
|
||||||
|
"installState.outdated": "Old version",
|
||||||
|
"installState.removed": "Removed",
|
||||||
"installsPluginWhere": "claude plugin install --scope user",
|
"installsPluginWhere": "claude plugin install --scope user",
|
||||||
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||||
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||||
@@ -185,7 +190,7 @@
|
|||||||
"stat.installs": "Installs",
|
"stat.installs": "Installs",
|
||||||
"stat.files": "Files",
|
"stat.files": "Files",
|
||||||
"stat.size": "Size",
|
"stat.size": "Size",
|
||||||
"stat.updated": "Updated",
|
"stat.updated": "Last updated",
|
||||||
"detail.runAs": "Run it as",
|
"detail.runAs": "Run it as",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Added {{date}}",
|
"detail.added": "Added {{date}}",
|
||||||
|
|||||||
@@ -143,6 +143,11 @@
|
|||||||
"installsYou": "Bạn",
|
"installsYou": "Bạn",
|
||||||
"installsUnknown": "Người dùng đã xoá",
|
"installsUnknown": "Người dùng đã xoá",
|
||||||
"installsReplaced": "thay bản cũ",
|
"installsReplaced": "thay bản cũ",
|
||||||
|
"versionAt": "Cập nhật {{ago}}",
|
||||||
|
"versionAtFull": "Cập nhật lần cuối {{date}}",
|
||||||
|
"installState.latest": "Bản mới nhất",
|
||||||
|
"installState.outdated": "Bản cũ",
|
||||||
|
"installState.removed": "Đã gỡ",
|
||||||
"installsPluginWhere": "claude plugin install --scope user",
|
"installsPluginWhere": "claude plugin install --scope user",
|
||||||
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
||||||
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||||
@@ -186,7 +191,7 @@
|
|||||||
"stat.installs": "Lượt cài",
|
"stat.installs": "Lượt cài",
|
||||||
"stat.files": "File",
|
"stat.files": "File",
|
||||||
"stat.size": "Dung lượng",
|
"stat.size": "Dung lượng",
|
||||||
"stat.updated": "Cập nhật",
|
"stat.updated": "Cập nhật lần cuối",
|
||||||
"detail.runAs": "Gọi bằng",
|
"detail.runAs": "Gọi bằng",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Thêm lúc {{date}}",
|
"detail.added": "Thêm lúc {{date}}",
|
||||||
|
|||||||
+11
-1
@@ -4720,11 +4720,21 @@ export interface Artifact {
|
|||||||
suggestedForMe?: boolean;
|
suggestedForMe?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** When its content last changed (a new snapshot); a page or plugin: when it was added. */
|
||||||
|
versionAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
|
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
|
||||||
export interface ArtifactInstalls {
|
export interface ArtifactInstalls {
|
||||||
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
|
items: {
|
||||||
|
user: string | null;
|
||||||
|
mine: boolean;
|
||||||
|
at: string;
|
||||||
|
replaced: boolean;
|
||||||
|
/** Skills: where that install's copy stands against the newest version now. */
|
||||||
|
state?: "latest" | "outdated" | "removed" | "unknown";
|
||||||
|
paths?: string[];
|
||||||
|
}[];
|
||||||
/** Installs counted before the list was kept. */
|
/** Installs counted before the list was kept. */
|
||||||
unrecorded: number;
|
unrecorded: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,10 @@
|
|||||||
* takes its folder on the server — or a plugin's copy — on a click, else
|
* takes its folder on the server — or a plugin's copy — on a click, else
|
||||||
* asks for the folder and uploads it onto the same row); an installed copy
|
* asks for the folder and uploads it onto the same row); an installed copy
|
||||||
* that differs from the newest version shows **Update** (a pack: "Update N"),
|
* that differs from the newest version shows **Update** (a pack: "Update N"),
|
||||||
* which replaces it after a confirmation, the old copy moved aside. Editing a skill
|
* which replaces it after a confirmation, the old copy moved aside. A skill or
|
||||||
|
* file row says when its CONTENT last changed ("Updated 3h ago", `versionAt`),
|
||||||
|
* and the install count's list marks each installer's copy Latest / Old
|
||||||
|
* version / Removed. Editing a skill
|
||||||
* edits its SKILL.md (name, description, instructions — a new snapshot);
|
* edits its SKILL.md (name, description, instructions — a new snapshot);
|
||||||
* its preview opens with what it installs as and when Claude uses it. Deleting a shared
|
* its preview opens with what it installs as and when Claude uses it. Deleting a shared
|
||||||
* row of your own only takes it out of your library — it stays in the Hub
|
* row of your own only takes it out of your library — it stays in the Hub
|
||||||
@@ -81,6 +84,7 @@ import { toast } from "../lib/toast";
|
|||||||
import { confirmDialog } from "../lib/confirm";
|
import { confirmDialog } from "../lib/confirm";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
|
import { timeAgo } from "../lib/format";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||||
import {
|
import {
|
||||||
FileUploadPanel,
|
FileUploadPanel,
|
||||||
@@ -1271,6 +1275,7 @@ function Meta({
|
|||||||
server: boolean;
|
server: boolean;
|
||||||
date?: boolean;
|
date?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
const parts: React.ReactNode[] = [];
|
const parts: React.ReactNode[] = [];
|
||||||
if (server && a.owner && !a.mine)
|
if (server && a.owner && !a.mine)
|
||||||
parts.push(
|
parts.push(
|
||||||
@@ -1278,7 +1283,18 @@ function Meta({
|
|||||||
{a.owner}
|
{a.owner}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
if (date)
|
// A skill or file is a snapshot that changes: when it last did. Anything else: when added.
|
||||||
|
if (a.kind === "skill" || a.kind === "file")
|
||||||
|
parts.push(
|
||||||
|
<span
|
||||||
|
key="at"
|
||||||
|
className="shrink-0"
|
||||||
|
title={t("versionAtFull", { date: new Date(a.versionAt).toLocaleString() })}
|
||||||
|
>
|
||||||
|
{t("versionAt", { ago: timeAgo(a.versionAt) })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
else if (date)
|
||||||
parts.push(
|
parts.push(
|
||||||
<span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
|
<span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
|
||||||
{fmtDate(a.createdAt)}
|
{fmtDate(a.createdAt)}
|
||||||
@@ -1536,7 +1552,10 @@ function DetailDialog({
|
|||||||
: []),
|
: []),
|
||||||
...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []),
|
...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []),
|
||||||
...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []),
|
...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []),
|
||||||
{ label: t("stat.updated"), value: fmtDate(a.updatedAt) },
|
{
|
||||||
|
label: t("stat.updated"),
|
||||||
|
value: new Date(a.versionAt).toLocaleString(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<Modal title={a.title} onClose={onClose} size="md">
|
<Modal title={a.title} onClose={onClose} size="md">
|
||||||
@@ -1707,6 +1726,13 @@ function DetailDialog({
|
|||||||
* The list floats over the page (a portal at the button, below it or above
|
* The list floats over the page (a portal at the button, below it or above
|
||||||
* when there is no room, kept inside the window): inside the card list's
|
* when there is no room, kept inside the window): inside the card list's
|
||||||
* `overflow-hidden` it was cut off at the last card. */
|
* `overflow-hidden` it was cut off at the last card. */
|
||||||
|
/** An installer's copy against the newest version: green, amber, grey. */
|
||||||
|
const STATE_CLASS = {
|
||||||
|
latest: "bg-status-success/15 text-status-success",
|
||||||
|
outdated: "bg-status-warning/15 text-status-warning",
|
||||||
|
removed: "bg-surface-2 text-fg-muted",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function InstallsButton({ item }: { item: Artifact }) {
|
function InstallsButton({ item }: { item: Artifact }) {
|
||||||
const { t } = useTranslation("artifacts");
|
const { t } = useTranslation("artifacts");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -1798,6 +1824,14 @@ function InstallsButton({ item }: { item: Artifact }) {
|
|||||||
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
{i.state && i.state !== "unknown" && (
|
||||||
|
<span
|
||||||
|
data-testid="install-state"
|
||||||
|
className={`shrink-0 rounded px-1 text-[10px] ${STATE_CLASS[i.state]}`}
|
||||||
|
>
|
||||||
|
{t(`installState.${i.state}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="shrink-0 text-[11px] text-fg-muted">
|
<span className="shrink-0 text-[11px] text-fg-muted">
|
||||||
{new Date(i.at).toLocaleString()}
|
{new Date(i.at).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
|
|||||||
installs: 0,
|
installs: 0,
|
||||||
createdAt: "2026-10-14T00:00:00Z",
|
createdAt: "2026-10-14T00:00:00Z",
|
||||||
updatedAt: "2026-10-14T00:00:00Z",
|
updatedAt: "2026-10-14T00:00:00Z",
|
||||||
|
versionAt: "2026-10-14T00:00:00Z",
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,9 +350,16 @@ describe("Artifacts", () => {
|
|||||||
mine: true,
|
mine: true,
|
||||||
at: "2026-10-19T10:00:00Z",
|
at: "2026-10-19T10:00:00Z",
|
||||||
replaced: false,
|
replaced: false,
|
||||||
|
state: "latest",
|
||||||
paths: ["/h/.claude/skills/deploy"],
|
paths: ["/h/.claude/skills/deploy"],
|
||||||
},
|
},
|
||||||
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
|
{
|
||||||
|
user: "khoa",
|
||||||
|
mine: false,
|
||||||
|
at: "2026-10-18T10:00:00Z",
|
||||||
|
replaced: true,
|
||||||
|
state: "outdated",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
unrecorded: 1,
|
unrecorded: 1,
|
||||||
});
|
});
|
||||||
@@ -363,6 +371,12 @@ describe("Artifacts", () => {
|
|||||||
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
||||||
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
||||||
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
||||||
|
// Each install says whether its copy is the newest version.
|
||||||
|
expect(
|
||||||
|
within(box)
|
||||||
|
.getAllByTestId("install-state")
|
||||||
|
.map((b) => b.textContent)
|
||||||
|
).toEqual(["Latest", "Old version"]);
|
||||||
// Over the page, outside the card list that clips its overflow — and a
|
// Over the page, outside the card list that clips its overflow — and a
|
||||||
// click inside it keeps it open.
|
// click inside it keeps it open.
|
||||||
expect(box.parentElement).toBe(document.body);
|
expect(box.parentElement).toBe(document.body);
|
||||||
@@ -436,6 +450,24 @@ describe("Artifacts", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says when a skill's content last changed, not when it was renamed", async () => {
|
||||||
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
item({
|
||||||
|
id: 2,
|
||||||
|
kind: "skill",
|
||||||
|
title: "deploy",
|
||||||
|
source: "deploy",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
versionAt: new Date(Date.now() - 3 * 3600_000).toISOString(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
open();
|
||||||
|
const card = await screen.findByTestId("artifact-2");
|
||||||
|
expect(within(card).getByText("Updated 3h ago")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
|
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
|
||||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.45 (rebuild client to match)
|
UI build v2.8.47 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -3147,7 +3147,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
|||||||
### Artifacts hub
|
### Artifacts hub
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` and `updateAvailable` (default scope `all`)
|
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
|
||||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
||||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||||
@@ -3167,7 +3167,7 @@ GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, n
|
|||||||
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
|
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
|
||||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||||
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
|
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
|
||||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
|
||||||
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
||||||
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
|
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
|
||||||
POST /api/artifacts/:id/assign {userId} → {item} — admin
|
POST /api/artifacts/:id/assign {userId} → {item} — admin
|
||||||
@@ -3176,7 +3176,7 @@ POST /api/artifacts/:id/assign {userId} → {item} — admin
|
|||||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||||
|
|
||||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
||||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked and not a web app's own page: never an `index.html`, one under `www`/`public`/`dist`/`build`/`src`/`static`/`templates`/`assets`/`site`/`_site`, or one beside `.js`/`.css`-type files in a folder other than the workspace root — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
|
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
|
||||||
- **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/<name>` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin.
|
- **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/<name>` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin.
|
||||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||||
|
|||||||
@@ -555,6 +555,10 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
|||||||
is used only for a pane that runs as its developer (one Linux user per
|
is used only for a pane that runs as its developer (one Linux user per
|
||||||
account): AgentHub writes the account's `auth.json` into that developer's
|
account): AgentHub writes the account's `auth.json` into that developer's
|
||||||
`~/.codex` as them (0600) just before the pane starts.
|
`~/.codex` as them (0600) just before the pane starts.
|
||||||
|
- The pane runs `codex --no-daemon` (when that Codex lists the flag in
|
||||||
|
`--help`): Codex ≥ 0.164 otherwise hands a plain session to the home's
|
||||||
|
shared app-server daemon, which outlives every pane and keeps the login
|
||||||
|
it started with — the new `auth.json` would not be used.
|
||||||
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
|
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
|
||||||
- A developer who signed Codex in themselves keeps their own login.
|
- A developer who signed Codex in themselves keeps their own login.
|
||||||
- While a pane of theirs runs on a pool account, their next pane uses the
|
- While a pane of theirs runs on a pool account, their next pane uses the
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.45",
|
"version": "2.8.47",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.45",
|
"version": "2.8.47",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.45",
|
"version": "2.8.47",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -685,6 +685,29 @@ describe("skills from files or written", () => {
|
|||||||
assert.equal(same.body.items[0].changed, false);
|
assert.equal(same.body.items[0].changed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("dates a skill by its content, and tells each install's copy from the newest", async () => {
|
||||||
|
const md = (v) => `---\nname: sg-state\ndescription: d\n---\nState ${v}.\n`;
|
||||||
|
const first = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(1)]]);
|
||||||
|
const id = first.body.items[0].id;
|
||||||
|
const at1 = first.body.items[0].versionAt;
|
||||||
|
assert.ok(at1);
|
||||||
|
const states = async () =>
|
||||||
|
(await request("GET", `/api/artifacts/${id}/installs`)).body.items.map((i) => i.state);
|
||||||
|
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
|
||||||
|
assert.deepEqual(await states(), ["latest"]);
|
||||||
|
|
||||||
|
// A rename is no new version; new content is.
|
||||||
|
const renamed = await request("PATCH", `/api/artifacts/${id}`, { title: "State" });
|
||||||
|
assert.equal(renamed.body.item.versionAt, at1);
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
const next = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(2)]]);
|
||||||
|
assert.ok(next.body.items[0].versionAt > at1);
|
||||||
|
assert.deepEqual(await states(), ["outdated"]);
|
||||||
|
|
||||||
|
fs.rmSync(path.join(process.env.CLAUDE_HOME, "skills", "sg-state"), { recursive: true });
|
||||||
|
assert.deepEqual(await states(), ["removed"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => {
|
it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => {
|
||||||
const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`;
|
const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`;
|
||||||
const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]);
|
const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]);
|
||||||
@@ -958,6 +981,23 @@ describe("outputs captured at a session's stop", () => {
|
|||||||
);
|
);
|
||||||
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
|
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves a web app's own pages alone and still takes a standalone HTML report", async () => {
|
||||||
|
const put = (rel, text = "<p>x</p>") => {
|
||||||
|
fs.mkdirSync(path.dirname(path.join(ws, rel)), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ws, rel), text);
|
||||||
|
const soon = new Date(Date.now() + 5 * 60 * 1000);
|
||||||
|
fs.utimesSync(path.join(ws, rel), soon, soon);
|
||||||
|
};
|
||||||
|
put("app/index.html");
|
||||||
|
put("app/www/page.html");
|
||||||
|
put("widget/view.html");
|
||||||
|
put("widget/view.js", "x()");
|
||||||
|
put("summary-report.html");
|
||||||
|
put("out/q3.html");
|
||||||
|
const got = (await artifacts.captureOutputs("s1")).map((r) => r.source).sort();
|
||||||
|
assert.deepEqual(got, ["out/q3.html", "summary-report.html"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("unowned rows", () => {
|
describe("unowned rows", () => {
|
||||||
|
|||||||
@@ -243,6 +243,29 @@ describe("codex pool: spawn", () => {
|
|||||||
assert.equal(row.account, "c");
|
assert.equal(row.account, "c");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs a ChatGPT pane without the shared daemon, only when its Codex has the flag", () => {
|
||||||
|
// The home's app-server daemon keeps the login it started with: a placed
|
||||||
|
// login reaches only a pane that runs its own app server.
|
||||||
|
const fakeCodex = (name, help) => {
|
||||||
|
const file = path.join(TMP, name);
|
||||||
|
fs.writeFileSync(file, `#!/bin/sh\necho '${help}'\n`, { mode: 0o755 });
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
const prev = process.env.AGENTHUB_CODEX_BIN;
|
||||||
|
try {
|
||||||
|
pool.addAccount("c", { auth: AUTH("acct-c") });
|
||||||
|
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-new", " --no-daemon Run without");
|
||||||
|
assert.deepEqual(pool.forSpawn(MIA).argv, ["--no-daemon"]);
|
||||||
|
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-old", " --oss Use a local model");
|
||||||
|
assert.deepEqual(pool.forSpawn(MIA).argv, [], "an older Codex would refuse the flag");
|
||||||
|
process.env.AGENTHUB_CODEX_BIN = path.join(TMP, "no-such-codex");
|
||||||
|
assert.deepEqual(pool.forSpawn(MIA).argv, []);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.AGENTHUB_CODEX_BIN;
|
||||||
|
else process.env.AGENTHUB_CODEX_BIN = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("never replaces a member's own login, and stays on the account their live pane uses", () => {
|
it("never replaces a member's own login, and stays on the account their live pane uses", () => {
|
||||||
pool.addAccount("c", { auth: AUTH("acct-c") });
|
pool.addAccount("c", { auth: AUTH("acct-c") });
|
||||||
pool.addAccount("d", { auth: AUTH("acct-d") });
|
pool.addAccount("d", { auth: AUTH("acct-d") });
|
||||||
@@ -272,7 +295,10 @@ describe("codex pool: spawn", () => {
|
|||||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
|
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
|
||||||
assert.equal(pool.pickAccount(MIA), null);
|
assert.equal(pool.pickAccount(MIA), null);
|
||||||
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
||||||
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-mias-seat");
|
assert.equal(
|
||||||
|
JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token,
|
||||||
|
"rt-mias-seat"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("copies the newest auth.json back into the pool", () => {
|
it("copies the newest auth.json back into the pool", () => {
|
||||||
|
|||||||
@@ -973,11 +973,16 @@ for (const [col, ddl] of [
|
|||||||
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
||||||
["version", "version TEXT"],
|
["version", "version TEXT"],
|
||||||
["source_hash", "source_hash TEXT"],
|
["source_hash", "source_hash TEXT"],
|
||||||
|
["version_at", "version_at TEXT"],
|
||||||
]) {
|
]) {
|
||||||
try {
|
try {
|
||||||
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
||||||
} catch {
|
} catch {
|
||||||
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
|
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
|
||||||
|
// `version_at`: when a row's CONTENT last changed (a new snapshot) — a
|
||||||
|
// rename or a share toggle moves `updated_at` but not this. An existing
|
||||||
|
// row starts from its `updated_at`, the best record there is.
|
||||||
|
if (col === "version_at") db.prepare("UPDATE artifacts SET version_at = updated_at").run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+67
-7
@@ -173,6 +173,8 @@ function view(row, req, owners = null) {
|
|||||||
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
|
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
// When its content last changed (a new snapshot): a page or plugin never does.
|
||||||
|
versionAt: row.version_at || row.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,13 +551,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
|||||||
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
|
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
|
||||||
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
|
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
|
||||||
const OUTPUT_MAX = 20;
|
const OUTPUT_MAX = 20;
|
||||||
|
/** Folders that hold a site's own pages, never a report someone reads. */
|
||||||
|
const SITE_DIR_RE = /^(www|public|dist|build|src|static|templates|assets|site|_site)$/i;
|
||||||
|
/** What sits next to a site's page: its scripts and styles. */
|
||||||
|
const SITE_ASSET_RE = /\.(m?js|jsx|tsx?|vue|svelte|css|s[ac]ss|less)$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An HTML file that is a web app's own page, not a report (`app/www/index.html`
|
||||||
|
* of a project not yet committed): an `index.html`, one under a site folder
|
||||||
|
* (`SITE_DIR_RE`), or one beside scripts or stylesheets in its folder. The
|
||||||
|
* workspace root is exempt from that last test — a repo keeps its config
|
||||||
|
* `.js` there next to the reports people write.
|
||||||
|
*/
|
||||||
|
async function isSitePage(cwd, rel) {
|
||||||
|
const parts = rel.split("/");
|
||||||
|
const name = parts.pop();
|
||||||
|
if (/^index\.html?$/i.test(name)) return true;
|
||||||
|
if (parts.some((d) => SITE_DIR_RE.test(d))) return true;
|
||||||
|
if (!parts.length) return false;
|
||||||
|
try {
|
||||||
|
const names = await fs.promises.readdir(path.join(cwd, ...parts));
|
||||||
|
return names.some((n) => SITE_ASSET_RE.test(n));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const capturing = new Map();
|
const capturing = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The workspace's documents a session created or changed since it started:
|
* The workspace's documents a session created or changed since it started:
|
||||||
* untracked or modified files in a repo (an HTML file only when untracked, so a
|
* untracked or modified files in a repo (an HTML file only when untracked and
|
||||||
* site's own pages are not taken), every file of a plain folder. Paths are
|
* not a site's own page — `isSitePage`), every file of a plain folder. Paths
|
||||||
* workspace-relative.
|
* are workspace-relative.
|
||||||
*/
|
*/
|
||||||
async function outputCandidates(lane, since) {
|
async function outputCandidates(lane, since) {
|
||||||
const gd = require("./git-diff");
|
const gd = require("./git-diff");
|
||||||
@@ -572,7 +599,7 @@ async function outputCandidates(lane, since) {
|
|||||||
const out = [];
|
const out = [];
|
||||||
for (const rel of new Set(paths)) {
|
for (const rel of new Set(paths)) {
|
||||||
if (!OUTPUT_EXT.test(rel)) continue;
|
if (!OUTPUT_EXT.test(rel)) continue;
|
||||||
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
|
if (/\.html?$/i.test(rel) && (tracked.has(rel) || (await isSitePage(lane.cwd, rel)))) continue;
|
||||||
let st;
|
let st;
|
||||||
try {
|
try {
|
||||||
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
||||||
@@ -627,7 +654,7 @@ async function captureOutputsNow(laneId, sessionId) {
|
|||||||
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
|
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
|
||||||
if (row) {
|
if (row) {
|
||||||
sql(
|
sql(
|
||||||
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||||
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
|
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
|
||||||
dropSnapshot(row.snapshot);
|
dropSnapshot(row.snapshot);
|
||||||
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
||||||
@@ -1641,7 +1668,8 @@ function editSkill(req, row, { name, description, content }) {
|
|||||||
sql(
|
sql(
|
||||||
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
||||||
version = ?, source_hash = ?,
|
version = ?, source_hash = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||||
|
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
n,
|
n,
|
||||||
snap.id,
|
snap.id,
|
||||||
@@ -2027,7 +2055,8 @@ function replaceSnapshot(row, files, sourceHash) {
|
|||||||
: row.description;
|
: row.description;
|
||||||
sql(
|
sql(
|
||||||
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
|
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
|
||||||
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||||
|
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
snap.id,
|
snap.id,
|
||||||
JSON.stringify(next),
|
JSON.stringify(next),
|
||||||
@@ -2308,6 +2337,36 @@ async function uninstall(req, id) {
|
|||||||
* the installer and an admin. `unrecorded` = installs counted before this
|
* the installer and an admin. `unrecorded` = installs counted before this
|
||||||
* list was kept.
|
* list was kept.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Where one recorded install of a skill stands now, from its Claude
|
||||||
|
* `skills/<name>` copies (one per account it went into; the cross-tool
|
||||||
|
* `~/.agents/skills` twins are the same files) —
|
||||||
|
* `outdated` when any differs from the row's newest snapshot (its user is
|
||||||
|
* offered Update), `removed` when none is there any more, `unknown` when one
|
||||||
|
* is there but unreadable as a skill, else `latest`. Only hashes are
|
||||||
|
* compared; no content leaves the server. The paths are the ones the server
|
||||||
|
* itself recorded at install, read like `updateCheck` reads the viewer's own.
|
||||||
|
*/
|
||||||
|
function installState(row, paths) {
|
||||||
|
const version = snapshotVersion(row);
|
||||||
|
const dirs = (Array.isArray(paths) ? paths : []).filter(
|
||||||
|
(p) =>
|
||||||
|
typeof p === "string" &&
|
||||||
|
path.basename(p) === row.source &&
|
||||||
|
path.basename(path.dirname(p)) === "skills" &&
|
||||||
|
path.basename(path.dirname(path.dirname(p))) !== ".agents"
|
||||||
|
);
|
||||||
|
if (!version || !dirs.length) return "unknown";
|
||||||
|
const states = dirs.map((dir) => {
|
||||||
|
const st = folderState(dir, path.dirname(path.dirname(dir)));
|
||||||
|
if (st) return st.hash === version ? "latest" : "outdated";
|
||||||
|
return exists(dir) ? "unknown" : "removed";
|
||||||
|
});
|
||||||
|
if (states.includes("outdated")) return "outdated";
|
||||||
|
if (states.every((x) => x === "removed")) return "removed";
|
||||||
|
return states.includes("unknown") ? "unknown" : "latest";
|
||||||
|
}
|
||||||
|
|
||||||
function installsOf(req, id) {
|
function installsOf(req, id) {
|
||||||
const row = load(req, id);
|
const row = load(req, id);
|
||||||
const u = req?.user;
|
const u = req?.user;
|
||||||
@@ -2331,6 +2390,7 @@ function installsOf(req, id) {
|
|||||||
mine,
|
mine,
|
||||||
at: r.installed_at,
|
at: r.installed_at,
|
||||||
replaced: r.replaced === 1,
|
replaced: r.replaced === 1,
|
||||||
|
...(row.kind === "skill" ? { state: installState(row, paths) } : {}),
|
||||||
...(mine || u?.role === "admin" ? { paths } : {}),
|
...(mine || u?.role === "admin" ? { paths } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
* home AgentHub placed that account in, never dated in the future, and
|
* home AgentHub placed that account in, never dated in the future, and
|
||||||
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
|
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
|
||||||
* spike showed an old refresh token still refreshes, so panes sharing an
|
* spike showed an old refresh token still refreshes, so panes sharing an
|
||||||
* account do not log each other out.
|
* account do not log each other out. Such a pane runs `--no-daemon`
|
||||||
|
* (when its Codex has the flag): the home's shared app-server daemon
|
||||||
|
* would keep serving the login it started with.
|
||||||
*
|
*
|
||||||
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
|
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
|
||||||
* 0700, file 0600); no route returns a key or a token.
|
* 0700, file 0600); no route returns a key or a token.
|
||||||
@@ -424,6 +426,46 @@ function liveInHome(name, member) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Codex ≥ 0.164 hands a plain TUI (no `-c` override) to the CODEX_HOME's shared
|
||||||
|
// app-server daemon, which outlives every TUI and keeps the login it started
|
||||||
|
// with — so a ChatGPT login placed in the home reached no pane while the
|
||||||
|
// member's daemon ran on the previous one (reproduced on 0.164.0). A pool pane
|
||||||
|
// runs its own app server instead. An older Codex has neither the daemon nor
|
||||||
|
// the flag (it would refuse to start), so the flag goes only to a binary whose
|
||||||
|
// --help lists it: probed once per binary (path, size, mtime).
|
||||||
|
const noDaemonCache = new Map();
|
||||||
|
|
||||||
|
function noDaemonArgv(member) {
|
||||||
|
const bin =
|
||||||
|
process.env.AGENTHUB_CODEX_BIN ||
|
||||||
|
(member
|
||||||
|
? require("./shared-tools").sharedPath("codex")
|
||||||
|
: require("./ai-tools").detect("codex").path);
|
||||||
|
if (!bin) return [];
|
||||||
|
let key;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(bin);
|
||||||
|
key = `${fs.realpathSync(bin)}:${st.size}:${st.mtimeMs}`;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!noDaemonCache.has(key)) {
|
||||||
|
let help = "";
|
||||||
|
try {
|
||||||
|
help = require("node:child_process").execFileSync(bin, ["--help"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* no answer: leave the flag out, as before */
|
||||||
|
}
|
||||||
|
noDaemonCache.set(key, /--no-daemon\b/.test(help));
|
||||||
|
}
|
||||||
|
return noDaemonCache.get(key) ? ["--no-daemon"] : [];
|
||||||
|
}
|
||||||
|
|
||||||
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
||||||
function pickAccount(member = null, ownerId = null) {
|
function pickAccount(member = null, ownerId = null) {
|
||||||
const a = pick(member, ownerId);
|
const a = pick(member, ownerId);
|
||||||
@@ -449,7 +491,7 @@ function forSpawn(member = null, ownerId = null) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name: a.name,
|
name: a.name,
|
||||||
argv: [],
|
argv: noDaemonArgv(member),
|
||||||
env: {},
|
env: {},
|
||||||
place: () => {
|
place: () => {
|
||||||
syncHome(member.home); // a live pane may hold a newer login than ours
|
syncHome(member.home); // a live pane may hold a newer login than ours
|
||||||
|
|||||||
Reference in New Issue
Block a user