diff --git a/apps/server/config.example.json b/apps/server/config.example.json
index 201c86c..ef1d4e9 100644
--- a/apps/server/config.example.json
+++ b/apps/server/config.example.json
@@ -3,7 +3,7 @@
"team": {
"name": "my-team",
"directive": "Ship and operate the payment processing service.",
- "brief": "We own the full lifecycle of the payment service: code, CI, deploys, and incident response. Our operating window is 0900-1700 UTC."
+ "context": "We own the full lifecycle of the payment service: code, CI, deploys, and incident response. Our operating window is 0900-1700 UTC."
},
"roles": {
"individual-contributor": {
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 3be5734..d6edc13 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -76,7 +76,13 @@ import { composeBriefing } from './briefing.js';
import { type BusyTracker, createBusyTracker } from './busy-tracker.js';
import { type ChannelStore, ChannelsError, GENERAL_CHANNEL_ID, validateSlug } from './channels.js';
import { type EnrollmentStore, formatUserCode, normalizeUserCode } from './enrollments.js';
-import { type FilesystemStore, FsError, type ViewerContext } from './files/index.js';
+import {
+ basenameOf,
+ type FilesystemStore,
+ FsError,
+ objectiveNamespacePath,
+ type ViewerContext,
+} from './files/index.js';
import type { JwtVerifier } from './jwt.js';
import type { Logger } from './logger.js';
import type { ActivityStore } from './member-activity.js';
@@ -1264,20 +1270,45 @@ export function createApp(options: AppOptions): CreatedApp {
assignee: created.assignee,
attachments: created.attachments.length,
});
- // Grant every initial thread member access to the attachments.
- // `objectiveThreadMembers` already knows the originator,
- // assignee, explicit watchers, and all admins — so one
- // call covers everyone who should see these files.
+
+ // Mirror each attachment into the objective's own namespace
+ // (`/objectives//...`) so the file's home is the
+ // objective, not whichever member uploaded it. The originator's
+ // home copy stays put — `copyByBlobRef` shares a single
+ // underlying blob, so bytes aren't duplicated, but each entry
+ // is independently deletable.
+ //
+ // No fallback: if any copy fails, the whole create surfaces
+ // the error. A half-mirrored objective with mixed
+ // namespace/pointer paths is harder to reason about than a
+ // clean retry, and there's no legacy data to coexist with.
+ let finalObjective = created;
if (files && created.attachments.length > 0) {
- const members = objectiveThreadMembers(created);
- grantAttachmentsTo(files, created.attachments, members, `obj:${created.id}`, logger);
+ const viewer = toViewer(member);
+ const namespacePaths: Attachment[] = created.attachments.map((att) => {
+ const dst = objectiveNamespacePath(created.id, basenameOf(att.path));
+ const copied = files.copyByBlobRef({
+ src: att.path,
+ dst,
+ mimeType: att.mimeType,
+ collision: 'suffix',
+ viewer,
+ });
+ return {
+ path: copied.path,
+ name: copied.name,
+ size: copied.size ?? att.size,
+ mimeType: copied.mimeType ?? att.mimeType,
+ };
+ });
+ finalObjective = objectives.setAttachments(created.id, namespacePaths);
}
queueMicrotask(() => {
for (const ev of events) {
- void publishObjectiveEvent(created, ev, member.name);
+ void publishObjectiveEvent(finalObjective, ev, member.name);
}
});
- return c.json(created);
+ return c.json(finalObjective);
} catch (err) {
const mapped = mapObjectivesError(err);
return c.json(mapped.body, mapped.status as 400 | 404 | 409 | 500);
@@ -1400,18 +1431,10 @@ export function createApp(options: AppOptions): CreatedApp {
}
try {
const { objective: updated, events } = objectives.reassign(id, parsed.data, member.name);
- // Backfill attachment grants for the new assignee — they're
- // now a thread member and should be able to download
- // anything that was attached to the objective at creation.
- if (files && updated.attachments.length > 0) {
- grantAttachmentsTo(
- files,
- updated.attachments,
- [updated.assignee],
- `obj:${updated.id}`,
- logger,
- );
- }
+ // Attachment access for the new assignee comes "for free" from
+ // the objective-namespace ACL — they're now a thread member,
+ // so `canRead('/objectives//...')` returns true via
+ // `isObjectiveMember`. No grant backfill needed.
queueMicrotask(() => {
for (const ev of events) {
void publishObjectiveEvent(updated, ev, member.name);
@@ -1473,21 +1496,11 @@ export function createApp(options: AppOptions): CreatedApp {
parsed.data,
member.name,
);
- // Every watcher_added event carries a name; backfill attachment
- // grants for each newly-added watcher so they can read files
- // that were attached to the objective before they joined the
- // thread.
- if (files && updated.attachments.length > 0) {
- const addedNames: string[] = [];
- for (const ev of events) {
- if (ev.kind === 'watcher_added' && typeof ev.payload.name === 'string') {
- addedNames.push(ev.payload.name);
- }
- }
- if (addedNames.length > 0) {
- grantAttachmentsTo(files, updated.attachments, addedNames, `obj:${updated.id}`, logger);
- }
- }
+ // Watcher membership changes have no FS-side bookkeeping to do:
+ // attachment access flows from `isObjectiveMember` in the
+ // namespace ACL, so adding a watcher grants access at the
+ // moment the membership lands and removing one revokes it the
+ // moment they're gone. No grant rows to backfill or sweep.
queueMicrotask(() => {
for (const ev of events) {
void publishObjectiveEvent(updated, ev, member.name);
@@ -1990,7 +2003,7 @@ export function createApp(options: AppOptions): CreatedApp {
// Read is dual-auth (every authenticated member sees their team).
// Mutations require `team.manage`. The response always reflects the
// freshly-read DB state — there is no in-memory snapshot to go
- // stale. Note: changing `directive` / `brief` / member `instructions`
+ // stale. Note: changing `directive` / `context` / member `instructions`
// takes effect on the *next* MCP session for any agent — those
// strings are baked into the MCP `instructions` field, which is
// frozen for the lifetime of a session by the protocol. Restart the
@@ -2007,12 +2020,12 @@ export function createApp(options: AppOptions): CreatedApp {
}
const body = (await c.req.json().catch(() => null)) as Record | null;
if (body === null) return c.json({ error: 'expected a JSON body' }, 400);
- const patch: { name?: string; directive?: string; brief?: string } = {};
+ const patch: { name?: string; directive?: string; context?: string } = {};
if (typeof body.name === 'string') patch.name = body.name;
if (typeof body.directive === 'string') patch.directive = body.directive;
- if (typeof body.brief === 'string') patch.brief = body.brief;
+ if (typeof body.context === 'string') patch.context = body.context;
if (Object.keys(patch).length === 0) {
- return c.json({ error: 'no fields to update (name, directive, brief)' }, 400);
+ return c.json({ error: 'no fields to update (name, directive, context)' }, 400);
}
try {
const updated = teamStore.updateTeam(patch, member.name);
@@ -2832,6 +2845,19 @@ export function createApp(options: AppOptions): CreatedApp {
return c.json({ entries });
});
+ // `/fs/all` — admin-only flat enumeration of every file in every
+ // home, newest-first. Non-admins use the per-home tree under
+ // `//...` for their own files and `/fs/shared` for the
+ // grants other members have given them.
+ app.get(PATHS.fsAll, auth, (c) => {
+ try {
+ const entries = fsStore.listAllFiles(toViewer(c.get('member')));
+ return c.json({ entries });
+ } catch (err) {
+ return mapFsError(c, err);
+ }
+ });
+
// `/fs/read/*` — catch-all, single URL-decoded segment per path
// component so `` just
// works. The `*` route lives in its own handler so Hono's
diff --git a/apps/server/src/briefing.ts b/apps/server/src/briefing.ts
index a1ab774..0698c4a 100644
--- a/apps/server/src/briefing.ts
+++ b/apps/server/src/briefing.ts
@@ -82,7 +82,7 @@ function composePrompt(self: Member, team: Team, others: Teammate[]): string {
``,
`Team: ${team.name}`,
`Directive: ${team.directive}`,
- team.brief.trim().length > 0 && `Brief: ${team.brief}`,
+ team.context.trim().length > 0 && `Context: ${team.context}`,
``,
selfInstructions.length > 0 && `Personal instructions:`,
selfInstructions.length > 0 && selfInstructions,
diff --git a/apps/server/src/files/filesystem-store.ts b/apps/server/src/files/filesystem-store.ts
index b00f180..3c75882 100644
--- a/apps/server/src/files/filesystem-store.ts
+++ b/apps/server/src/files/filesystem-store.ts
@@ -40,6 +40,7 @@ import {
normalizePath,
ownerOf,
parentOf,
+ parseObjectiveNamespacePath,
ROOT_PATH,
} from './paths.js';
@@ -131,10 +132,42 @@ export interface WriteFileResult {
renamed: boolean;
}
+/**
+ * Hook for the objective-namespace ACL gate. Decoupled from the
+ * objectives store so the FS layer doesn't have to know what an
+ * objective row looks like — it only needs the membership question
+ * answered.
+ *
+ * `isMember` returns true if `viewerName` is the originator, the
+ * current assignee, or one of the watchers of `objectiveId`. The FS
+ * store calls this whenever a path under `/objectives//...` is
+ * read, written, or deleted by a non-admin viewer.
+ *
+ * Optional at construction time — without a provider, the namespace
+ * is silently treated as forbidden for non-admins (read/write 403),
+ * which is the right failure mode while the caller is wiring things up.
+ */
+export interface ObjectiveAclProvider {
+ isMember(objectiveId: string, viewerName: string): boolean;
+}
+
export interface FilesystemStore {
stat(path: string, viewer: ViewerContext): FsEntry | null;
list(path: string, viewer: ViewerContext): FsEntry[];
listShared(viewer: ViewerContext): FsEntry[];
+ /** Admin-only flat enumeration of every file across the team. */
+ listAllFiles(viewer: ViewerContext): FsEntry[];
+
+ /**
+ * Create a new file entry that points at the same blob as `srcPath`.
+ * Refcount-aware — the blob bytes are not duplicated; only the
+ * metadata row is. Used to materialize objective attachments into
+ * `/objectives//...` while keeping the originator's home copy
+ * intact. Source must be readable by `viewer`; destination must be
+ * writable by `viewer`. Honors the same collision strategies as
+ * `writeFile`.
+ */
+ copyByBlobRef(input: CopyByBlobRefInput): FsEntry;
openReadStream(path: string, viewer: ViewerContext): { entry: FsEntry; stream: Readable };
@@ -160,14 +193,31 @@ export interface FilesystemStore {
ensureHome(slotName: string, now?: number): void;
}
+export interface CopyByBlobRefInput {
+ src: string;
+ dst: string;
+ mimeType?: string;
+ /** Default 'error'. Same semantics as `writeFile`. */
+ collision?: WriteCollisionStrategy;
+ viewer: ViewerContext;
+}
+
interface SqliteFilesystemStoreOptions {
db: DatabaseSyncInstance;
blobs: BlobStore;
+ /**
+ * ACL provider for the `/objectives//...` namespace. Injected
+ * rather than imported so the FS package has no inbound dependency
+ * on the objectives store. Optional — without it, namespace paths
+ * 403 for non-admins.
+ */
+ objectiveAcl?: ObjectiveAclProvider;
}
class SqliteFilesystemStore implements FilesystemStore {
private readonly db: DatabaseSyncInstance;
private readonly blobs: BlobStore;
+ private readonly objectiveAcl: ObjectiveAclProvider | null;
private readonly getEntryStmt: StatementInstance;
private readonly listChildrenStmt: StatementInstance;
@@ -185,10 +235,12 @@ class SqliteFilesystemStore implements FilesystemStore {
private readonly hasGrantStmt: StatementInstance;
private readonly movePathStmt: StatementInstance;
private readonly listHomesStmt: StatementInstance;
+ private readonly listAllFilesStmt: StatementInstance;
constructor(opts: SqliteFilesystemStoreOptions) {
this.db = opts.db;
this.blobs = opts.blobs;
+ this.objectiveAcl = opts.objectiveAcl ?? null;
this.db.exec(CREATE_SCHEMA);
this.getEntryStmt = this.db.prepare('SELECT * FROM fs_entries WHERE path = ?');
@@ -237,6 +289,9 @@ class SqliteFilesystemStore implements FilesystemStore {
this.listHomesStmt = this.db.prepare(
"SELECT * FROM fs_entries WHERE parent_path = '/' AND kind = 'directory' ORDER BY name",
);
+ this.listAllFilesStmt = this.db.prepare(
+ "SELECT * FROM fs_entries WHERE kind = 'file' ORDER BY updated_at DESC",
+ );
}
// ─── permissions ────────────────────────────────────────────────
@@ -246,15 +301,30 @@ class SqliteFilesystemStore implements FilesystemStore {
return ownerOf(path) === viewer.name;
}
+ /**
+ * Membership in the objective whose namespace contains `path`. Returns
+ * `false` for paths outside the objective namespace and for the bare
+ * `/objectives` parent (which has no specific objective). Used as the
+ * ACL gate for both read and write under `/objectives//...`.
+ */
+ private isObjectiveMember(path: string, viewer: ViewerContext): boolean {
+ if (this.objectiveAcl === null) return false;
+ const parsed = parseObjectiveNamespacePath(path);
+ if (parsed === null) return false;
+ return this.objectiveAcl.isMember(parsed.id, viewer.name);
+ }
+
private canRead(path: string, viewer: ViewerContext): boolean {
if (viewer.permissions.includes('members.manage')) return true;
if (this.ownsPath(path, viewer)) return true;
+ if (this.isObjectiveMember(path, viewer)) return true;
return this.hasGrant(path, viewer.name);
}
private canWrite(path: string, viewer: ViewerContext): boolean {
if (viewer.permissions.includes('members.manage')) return true;
- return this.ownsPath(path, viewer);
+ if (this.ownsPath(path, viewer)) return true;
+ return this.isObjectiveMember(path, viewer);
}
// ─── read API ──────────────────────────────────────────────────
@@ -306,6 +376,20 @@ class SqliteFilesystemStore implements FilesystemStore {
return rows.map(rowToEntry);
}
+ /**
+ * Flat list of every file across every home, newest first. Admin-only —
+ * the existing tree navigation under `//` is the right path
+ * for non-directors, who shouldn't see the global file list. Throws
+ * `forbidden` if the caller lacks `members.manage`.
+ */
+ listAllFiles(viewer: ViewerContext): FsEntry[] {
+ if (!viewer.permissions.includes('members.manage')) {
+ throw new FsError('forbidden', 'admin permission required to list all files');
+ }
+ const rows = this.listAllFilesStmt.all() as unknown as FsEntryRow[];
+ return rows.map(rowToEntry);
+ }
+
openReadStream(path: string, viewer: ViewerContext): { entry: FsEntry; stream: Readable } {
const entry = this.stat(path, viewer);
if (!entry) throw new FsError('not_found', `no such path: ${path}`);
@@ -408,6 +492,102 @@ class SqliteFilesystemStore implements FilesystemStore {
return { entry: rowToEntry(row), renamed };
}
+ /**
+ * Materialize a new entry at `dst` that shares the underlying blob
+ * with `src`. Used to mount objective attachments into
+ * `/objectives//...` without duplicating blob bytes.
+ *
+ * Refcount semantics: the shared blob's refcount is incremented by
+ * one — the source entry still holds its own reference, so deleting
+ * either entry leaves the other intact, and the bytes are dropped
+ * from disk only when the last entry referencing them goes away.
+ */
+ copyByBlobRef(input: CopyByBlobRefInput): FsEntry {
+ const src = normalizePath(input.src);
+ const dstNormalized = normalizePath(input.dst);
+ if (dstNormalized === ROOT_PATH) {
+ throw new FsError('invalid_input', 'cannot copy to root');
+ }
+ if (!this.canRead(src, input.viewer)) {
+ throw new FsError('forbidden', `cannot read source: ${src}`);
+ }
+ if (!this.canWrite(dstNormalized, input.viewer)) {
+ throw new FsError('forbidden', `cannot write to destination: ${dstNormalized}`);
+ }
+ const srcRow = this.getEntryStmt.get(src) as FsEntryRow | undefined;
+ if (!srcRow) throw new FsError('not_found', `no such source: ${src}`);
+ if (srcRow.kind !== 'file') {
+ throw new FsError('is_a_directory', `source is a directory: ${src}`);
+ }
+ if (!srcRow.content_hash || srcRow.size === null) {
+ throw new FsError('corrupt', `source has no content: ${src}`);
+ }
+ const mimeType = input.mimeType ?? srcRow.mime_type ?? 'application/octet-stream';
+ const now = Date.now();
+
+ // Auto-create dst ancestors (e.g. `/objectives` and
+ // `/objectives/`) before the metadata transaction. Same
+ // pattern as `writeFile` — keeps mkdir its own idempotent step.
+ this.ensureDirectoryTree(parentOf(dstNormalized), input.viewer, now);
+
+ const collision = input.collision ?? 'error';
+ let finalPath = dstNormalized;
+
+ this.withTx(() => {
+ let existing = this.getEntryStmt.get(finalPath) as FsEntryRow | undefined;
+ if (existing) {
+ if (existing.kind === 'directory') {
+ throw new FsError('is_a_directory', `cannot overwrite directory: ${finalPath}`);
+ }
+ if (collision === 'error') {
+ throw new FsError('exists', `already exists: ${finalPath}`);
+ }
+ if (collision === 'suffix') {
+ const parentPath = existing.parent_path;
+ const base = basenameOf(finalPath);
+ const newName = dedupeBasename(base, (candidate) => {
+ const p = joinPath(parentPath, candidate);
+ return this.getEntryStmt.get(p) !== undefined;
+ });
+ finalPath = joinPath(parentPath, newName);
+ existing = undefined;
+ }
+ }
+
+ if (existing && collision === 'overwrite') {
+ const priorHash = existing.content_hash;
+ this.updateEntryContentStmt.run(srcRow.content_hash, srcRow.size, mimeType, now, finalPath);
+ this.incRefStmt.run(srcRow.content_hash);
+ if (priorHash && priorHash !== srcRow.content_hash) {
+ this.decrementAndMaybeDropBlob(priorHash);
+ }
+ } else {
+ const ownerName = ownerOf(finalPath);
+ if (!ownerName) {
+ throw new FsError('invalid_input', 'cannot write directly under root');
+ }
+ this.insertEntryStmt.run(
+ finalPath,
+ parentOf(finalPath),
+ basenameOf(finalPath),
+ 'file',
+ ownerName,
+ srcRow.content_hash,
+ srcRow.size,
+ mimeType,
+ now,
+ input.viewer.name,
+ now,
+ );
+ this.incRefStmt.run(srcRow.content_hash);
+ }
+ });
+
+ const row = this.getEntryStmt.get(finalPath) as FsEntryRow | undefined;
+ if (!row) throw new FsError('corrupt', `entry vanished after copy: ${finalPath}`);
+ return rowToEntry(row);
+ }
+
mkdir(path: string, writer: ViewerContext, opts: { recursive?: boolean } = {}): FsEntry {
const normalized = normalizePath(path);
if (normalized === ROOT_PATH) {
diff --git a/apps/server/src/files/index.ts b/apps/server/src/files/index.ts
index 1494f33..66c16ab 100644
--- a/apps/server/src/files/index.ts
+++ b/apps/server/src/files/index.ts
@@ -1,8 +1,10 @@
export { type BlobStore, LocalBlobStore, type PutOptions, type PutResult } from './blob-store.js';
export { FsError, type FsErrorCode } from './errors.js';
export {
+ type CopyByBlobRefInput,
createSqliteFilesystemStore,
type FilesystemStore,
+ type ObjectiveAclProvider,
type ViewerContext,
type WriteCollisionStrategy,
type WriteFileInput,
@@ -16,8 +18,12 @@ export {
MAX_PATH_LENGTH,
MAX_SEGMENT_LENGTH,
normalizePath,
+ OBJECTIVE_NAMESPACE_SEGMENT,
+ OBJECTIVE_OWNER_PREFIX,
+ objectiveNamespacePath,
ownerOf,
parentOf,
+ parseObjectiveNamespacePath,
ROOT_PATH,
splitPath,
} from './paths.js';
diff --git a/apps/server/src/files/paths.ts b/apps/server/src/files/paths.ts
index 28f3b42..3c2c0d2 100644
--- a/apps/server/src/files/paths.ts
+++ b/apps/server/src/files/paths.ts
@@ -1,14 +1,23 @@
/**
* Path utilities for the ac7 filesystem.
*
- * Paths are absolute, Unix-like, with `/` as separator. The first
- * segment is the owner (a slot name). Segments may contain
- * alphanumerics, dot, underscore, hyphen, and single spaces; no
- * `.`/`..` traversal; no leading/trailing whitespace; no empty
+ * Paths are absolute, Unix-like, with `/` as separator. Segments may
+ * contain alphanumerics, dot, underscore, hyphen, and single spaces;
+ * no `.`/`..` traversal; no leading/trailing whitespace; no empty
* segments between slashes.
*
+ * Two top-level scopes share the path tree:
+ * - **Member homes** at `//` — the original per-slot
+ * scope. The first path segment is the member name; that name is
+ * also the row's `owner` value, and the read/write ACL is
+ * "owner-only + admin + grant-holders".
+ * - **Objective namespaces** at `/objectives//` — a per-objective
+ * scope owned not by an individual but by the objective's member
+ * set (originator + assignee + watchers). Rows under this prefix
+ * have `owner = 'obj:'`; the ACL gate is "member of the
+ * objective + admin".
+ *
* The root `/` has no owner and is implicit (no DB row represents it).
- * Every slot's home is `//`.
*/
import { FsError } from './errors.js';
@@ -18,6 +27,15 @@ export const ROOT_PATH = '/' as const;
export const MAX_PATH_LENGTH = 1024;
export const MAX_SEGMENT_LENGTH = 255;
+/**
+ * Top-level segment for the objective-scope namespace. We use the
+ * spelled-out word `objectives` in paths to keep them readable, while
+ * the `owner` column carries the abbreviated `obj:` form so it
+ * matches the `obj:` thread-key prefix used elsewhere.
+ */
+export const OBJECTIVE_NAMESPACE_SEGMENT = 'objectives';
+export const OBJECTIVE_OWNER_PREFIX = 'obj:';
+
const SEGMENT_RE = /^[a-zA-Z0-9._\- ]+$/;
/**
@@ -84,14 +102,52 @@ export function basenameOf(path: string): string {
}
/**
- * First segment — the slot that owns this subtree. Root → null (no owner).
+ * Authoritative `owner` column value for the row at `path`.
+ *
+ * `/alice/...` → `'alice'` (member-home scope)
+ * `/objectives/foo/...` → `'obj:foo'` (objective scope)
+ * `/objectives` → `'objectives'` (the bare namespace dir;
+ * no individual owns it)
+ * `/` → `null` (root; no DB row)
+ *
+ * Centralizing this here means write-time row creation and ACL checks
+ * agree on the same scope tag without each call site re-deriving it.
*/
export function ownerOf(path: string): string | null {
const segments = splitPath(path);
if (segments.length === 0) return null;
+ if (segments[0] === OBJECTIVE_NAMESPACE_SEGMENT) {
+ if (segments.length === 1) return OBJECTIVE_NAMESPACE_SEGMENT;
+ return `${OBJECTIVE_OWNER_PREFIX}${segments[1]}`;
+ }
return segments[0] as string;
}
+/**
+ * If `path` lives in the objective namespace, return its `id` and the
+ * subpath under that objective's root. Returns `null` for any other
+ * path (member homes, root, the bare `/objectives` parent).
+ *
+ * `/objectives/foo/spec.pdf` → `{ id: 'foo', subpath: 'spec.pdf' }`
+ * `/objectives/foo` → `{ id: 'foo', subpath: '' }`
+ * `/objectives` → `null` (the bare namespace dir
+ * belongs to no specific objective)
+ * `/alice/...` → `null`
+ */
+export function parseObjectiveNamespacePath(path: string): { id: string; subpath: string } | null {
+ const segments = splitPath(path);
+ if (segments.length < 2) return null;
+ if (segments[0] !== OBJECTIVE_NAMESPACE_SEGMENT) return null;
+ const id = segments[1] as string;
+ const subpath = segments.slice(2).join('/');
+ return { id, subpath };
+}
+
+/** Build a normalized path under an objective's namespace. */
+export function objectiveNamespacePath(id: string, ...subpath: string[]): string {
+ return joinPath(`/${OBJECTIVE_NAMESPACE_SEGMENT}`, id, ...subpath);
+}
+
/**
* Join parts into a single normalized path. Empty segments are
* dropped. Useful for composing a home + subpath without worrying
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 6e497c1..6230469 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -274,7 +274,7 @@ async function main(): Promise {
stores.team.setTeam({
name: wizard.team.name,
directive: wizard.team.directive,
- brief: wizard.team.brief,
+ context: wizard.team.context,
});
for (const [name, leaves] of Object.entries(wizard.team.permissionPresets)) {
stores.team.setPreset(name, leaves);
diff --git a/apps/server/src/members.ts b/apps/server/src/members.ts
index f557d52..c247bd4 100644
--- a/apps/server/src/members.ts
+++ b/apps/server/src/members.ts
@@ -21,7 +21,7 @@
* "team": {
* "name": "demo-team",
* "directive": "Ship the payment service.",
- * "brief": "We own the full lifecycle...",
+ * "context": "We own the full lifecycle...",
* "permissionPresets": {
* "admin": ["team.manage", "members.manage", "objectives.create", "objectives.cancel", "objectives.reassign", "objectives.watch", "activity.read"],
* "operator": ["objectives.create", "objectives.cancel", "objectives.reassign"]
@@ -112,7 +112,7 @@ const PermissionLeafSchema = z.enum(PERMISSIONS);
const TeamNameSchema = z.string().min(1).max(128);
const TeamDirectiveSchema = z.string().min(1).max(512);
-const TeamBriefSchema = z.string().max(4096).default('');
+const TeamContextSchema = z.string().max(4096).default('');
const MemberNameSchema = z
.string()
.min(1)
@@ -220,11 +220,11 @@ export function validateTeamDirective(directive: string): void {
failFromZod('team.directive', err);
}
}
-export function validateTeamBrief(brief: string): void {
+export function validateTeamContext(context: string): void {
try {
- TeamBriefSchema.parse(brief);
+ TeamContextSchema.parse(context);
} catch (err) {
- failFromZod('team.brief', err);
+ failFromZod('team.context', err);
}
}
export function validateMemberName(name: string): void {
diff --git a/apps/server/src/objectives.ts b/apps/server/src/objectives.ts
index 2303942..afea057 100644
--- a/apps/server/src/objectives.ts
+++ b/apps/server/src/objectives.ts
@@ -256,6 +256,14 @@ export interface ObjectivesStore {
actor: string,
now?: number,
): ObjectivesMutationResult;
+ /**
+ * Replace the `attachments` JSON column without producing an audit
+ * event. Used by the server route after `create` to swap the
+ * originally-claimed attachment paths (in member homes) for their
+ * mirrored copies in the `/objectives//...` namespace. Returns
+ * the updated objective.
+ */
+ setAttachments(id: string, attachments: Attachment[], now?: number): Objective;
}
class SqliteObjectivesStore implements ObjectivesStore {
@@ -268,6 +276,7 @@ class SqliteObjectivesStore implements ObjectivesStore {
private readonly insertStmt: StatementInstance;
private readonly updateRowStmt: StatementInstance;
private readonly updateWatchersStmt: StatementInstance;
+ private readonly updateAttachmentsStmt: StatementInstance;
private readonly insertEventStmt: StatementInstance;
private readonly listEventsStmt: StatementInstance;
@@ -315,6 +324,9 @@ class SqliteObjectivesStore implements ObjectivesStore {
this.updateWatchersStmt = db.prepare(
'UPDATE objectives SET watchers = ?, updated_at = ? WHERE id = ?',
);
+ this.updateAttachmentsStmt = db.prepare(
+ 'UPDATE objectives SET attachments = ?, updated_at = ? WHERE id = ?',
+ );
this.insertEventStmt = db.prepare(
'INSERT INTO objective_events (objective_id, ts, actor, kind, payload) VALUES (?, ?, ?, ?, ?)',
);
@@ -703,6 +715,15 @@ class SqliteObjectivesStore implements ObjectivesStore {
return { objective: updated, events };
}
+ setAttachments(id: string, attachments: Attachment[], now = Date.now()): Objective {
+ const current = this.get(id);
+ if (!current) throw new ObjectivesError('not_found', `objective ${id} not found`);
+ this.updateAttachmentsStmt.run(JSON.stringify(attachments), now, id);
+ const updated = this.get(id);
+ if (!updated) throw new ObjectivesError('not_found', `objective ${id} vanished after update`);
+ return updated;
+ }
+
private appendEvent(
id: string,
ts: number,
diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts
index 867f7cf..3d172ea 100644
--- a/apps/server/src/run.ts
+++ b/apps/server/src/run.ts
@@ -363,7 +363,24 @@ export async function runServer(options: RunServerOptions): Promise {
+): Promise<{ name: string; directive: string; context: string }> {
const name = await promptRequired(io, 'team name [my-team]: ', 'my-team', (v) =>
v.length > 0 && v.length <= 128 ? null : 'must be 1-128 characters',
);
@@ -179,8 +179,10 @@ async function promptTeam(
'',
(v) => (v.length > 0 && v.length <= 512 ? null : 'directive is required, max 512 chars'),
);
- const brief = (await io.prompt('brief (longer context, press enter to skip): ')).trim();
- return { name, directive, brief };
+ const context = (
+ await io.prompt('team context (longer background, press enter to skip): ')
+ ).trim();
+ return { name, directive, context };
}
async function promptRequired(
diff --git a/apps/server/test/agent-activity.test.ts b/apps/server/test/agent-activity.test.ts
index 43be03b..8fda0cb 100644
--- a/apps/server/test/agent-activity.test.ts
+++ b/apps/server/test/agent-activity.test.ts
@@ -40,7 +40,7 @@ const OTHER_TOKEN = 'ac7_test_other';
const TEAM: Team = {
name: 'demo-team',
directive: 'Ship the payments service.',
- brief: 'End-to-end ownership.',
+ context: 'End-to-end ownership.',
permissionPresets: {},
};
diff --git a/apps/server/test/app.test.ts b/apps/server/test/app.test.ts
index cc6fe26..cda2e4a 100644
--- a/apps/server/test/app.test.ts
+++ b/apps/server/test/app.test.ts
@@ -15,7 +15,7 @@ const BOT_TOKEN = 'ac7_test_bot_secret';
const TEAM: Team = {
name: 'demo-team',
directive: 'Ship and operate the payment service.',
- brief: 'We own the full lifecycle.',
+ context: 'We own the full lifecycle.',
permissionPresets: {},
};
diff --git a/apps/server/test/auth.test.ts b/apps/server/test/auth.test.ts
index c0e6fc0..bb69672 100644
--- a/apps/server/test/auth.test.ts
+++ b/apps/server/test/auth.test.ts
@@ -28,7 +28,7 @@ const BOT_TOKEN = 'ac7_auth_test_bot_token';
const TEAM: Team = {
name: 'demo-team',
directive: 'Verify the auth surface.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/briefing.test.ts b/apps/server/test/briefing.test.ts
index 0725a25..a403d00 100644
--- a/apps/server/test/briefing.test.ts
+++ b/apps/server/test/briefing.test.ts
@@ -5,7 +5,7 @@ import { composeBriefing } from '../src/briefing.js';
const TEAM: Team = {
name: 'demo-team',
directive: 'Ship the payment service.',
- brief: 'We own the full lifecycle of the payment service.',
+ context: 'We own the full lifecycle of the payment service.',
permissionPresets: {},
};
@@ -73,7 +73,7 @@ describe('composeBriefing', () => {
expect(briefing.instructions).toContain('Your role here: engineer');
expect(briefing.instructions).toContain(TEAM.name);
expect(briefing.instructions).toContain(TEAM.directive);
- expect(briefing.instructions).toContain(TEAM.brief);
+ expect(briefing.instructions).toContain(TEAM.context);
expect(briefing.instructions).toContain(ALPHA_1.instructions);
});
@@ -94,16 +94,16 @@ describe('composeBriefing', () => {
expect(linesAfterHeader).not.toMatch(/^\s{2}engineer-1\s/m);
});
- it('omits the brief line when team.brief is empty', () => {
- const teamNoBrief: Team = { ...TEAM, brief: '' };
+ it('omits the context line when team.context is empty', () => {
+ const teamNoContext: Team = { ...TEAM, context: '' };
const briefing = composeBriefing({
self: DIRECTOR,
- team: teamNoBrief,
+ team: teamNoContext,
teammates: TEAMMATES,
openObjectives: [],
});
- expect(briefing.instructions).not.toContain('Brief:');
- expect(briefing.instructions).toContain(`Directive: ${teamNoBrief.directive}`);
+ expect(briefing.instructions).not.toContain('Context:');
+ expect(briefing.instructions).toContain(`Directive: ${teamNoContext.directive}`);
});
it('omits the personal-instructions block when the member has none', () => {
diff --git a/apps/server/test/channels-endpoints.test.ts b/apps/server/test/channels-endpoints.test.ts
index e20771e..7edb1ff 100644
--- a/apps/server/test/channels-endpoints.test.ts
+++ b/apps/server/test/channels-endpoints.test.ts
@@ -16,7 +16,7 @@ const CAROL = 'ac7_test_carol_secret';
const TEAM: Team = {
name: 'demo-team',
directive: 'Ship the thing.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/e2e.test.ts b/apps/server/test/e2e.test.ts
index fb2fdd8..f98ae04 100644
--- a/apps/server/test/e2e.test.ts
+++ b/apps/server/test/e2e.test.ts
@@ -50,7 +50,7 @@ const PEER_TOKEN = 'ac7_test_peer';
const TEAM: Team = {
name: 'e2e-team',
directive: 'Exercise the full ac7 stack end-to-end.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/enroll-endpoints.test.ts b/apps/server/test/enroll-endpoints.test.ts
index 0850dd1..a912835 100644
--- a/apps/server/test/enroll-endpoints.test.ts
+++ b/apps/server/test/enroll-endpoints.test.ts
@@ -37,7 +37,7 @@ const NON_ADMIN_TOKEN = 'ac7_enroll_engineer_token';
const TEAM: Team = {
name: 'enroll-team',
directive: 'Exercise the device-code flow.',
- brief: '',
+ context: '',
permissionPresets: {
admin: [
'team.manage',
diff --git a/apps/server/test/files/fs-routes.test.ts b/apps/server/test/files/fs-routes.test.ts
index 8646c76..dec30e2 100644
--- a/apps/server/test/files/fs-routes.test.ts
+++ b/apps/server/test/files/fs-routes.test.ts
@@ -28,7 +28,7 @@ const DIRECTOR_TOKEN = 'ac7_test_director_secret';
const TEAM: Team = {
name: 'files-team',
directive: 'Exercise filesystem endpoints.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/files/objective-namespace.test.ts b/apps/server/test/files/objective-namespace.test.ts
new file mode 100644
index 0000000..fd97155
--- /dev/null
+++ b/apps/server/test/files/objective-namespace.test.ts
@@ -0,0 +1,290 @@
+/**
+ * End-to-end tests for the `/objectives//...` filesystem
+ * namespace.
+ *
+ * Wires the FS store + objectives store + Hono routes through a real
+ * in-memory SQLite + temp blob root so attachment mirroring,
+ * membership-based ACL, and watcher-removal grant cleanup all
+ * exercise together.
+ *
+ * What we're trying to prove here:
+ * 1. Creating an objective with attachments mirrors the file into
+ * `/objectives//` and updates the objective's
+ * attachments to point at the namespace path. The originator's
+ * home copy stays put (so deletes from the home don't break the
+ * objective).
+ * 2. Members of the objective (originator, assignee, watchers) can
+ * read AND write under the namespace. Non-members 403.
+ * 3. Removing a watcher revokes their `obj:` grants on legacy
+ * pointer attachments — the (b) bug fix.
+ */
+
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Broker, InMemoryEventLog } from '@agentc7/core';
+import type { Objective, Team } from '@agentc7/sdk/types';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { createApp } from '../../src/app.js';
+import { openDatabase } from '../../src/db.js';
+import { createSqliteFilesystemStore, LocalBlobStore } from '../../src/files/index.js';
+import { createMemberStore } from '../../src/members.js';
+import { createSqliteObjectivesStore } from '../../src/objectives.js';
+import { SessionStore } from '../../src/sessions.js';
+import { createTokenStoreFromMembers } from '../../src/tokens.js';
+import { mockTeamStore } from '../helpers/test-stores.js';
+
+const ALICE = 'ac7_test_alice_secret';
+const BOB = 'ac7_test_bob_secret';
+const CAROL = 'ac7_test_carol_secret';
+const DAVE = 'ac7_test_dave_secret';
+
+const TEAM: Team = {
+ name: 'obj-fs-team',
+ directive: 'Files live with their objective.',
+ context: '',
+ permissionPresets: {},
+};
+
+const tmpDirs: string[] = [];
+
+afterEach(() => {
+ for (const d of tmpDirs.splice(0)) {
+ rmSync(d, { recursive: true, force: true });
+ }
+});
+
+function makeApp() {
+ const broker = new Broker({
+ eventLog: new InMemoryEventLog(),
+ now: () => 1_700_000_000_000,
+ idFactory: (() => {
+ let n = 0;
+ return () => `msg-${++n}`;
+ })(),
+ });
+ const members = createMemberStore([
+ {
+ name: 'alice',
+ role: { title: 'admin', description: '' },
+ permissions: ['members.manage', 'objectives.create', 'objectives.watch'],
+ token: ALICE,
+ },
+ {
+ name: 'bob',
+ role: { title: 'engineer', description: '' },
+ permissions: ['objectives.create'],
+ token: BOB,
+ },
+ {
+ name: 'carol',
+ role: { title: 'engineer', description: '' },
+ permissions: [],
+ token: CAROL,
+ },
+ {
+ name: 'dave',
+ role: { title: 'engineer', description: '' },
+ permissions: [],
+ token: DAVE,
+ },
+ ]);
+ broker.seedMembers(members.members());
+ const db = openDatabase(':memory:');
+ const sessions = new SessionStore(db);
+ const tokens = createTokenStoreFromMembers(db, members);
+ const blobDir = mkdtempSync(join(tmpdir(), 'ac7-objfs-'));
+ tmpDirs.push(blobDir);
+ const blobs = new LocalBlobStore(blobDir);
+ const objectives = createSqliteObjectivesStore(db);
+ const files = createSqliteFilesystemStore({
+ db,
+ blobs,
+ objectiveAcl: {
+ isMember(objectiveId, viewerName) {
+ const obj = objectives.get(objectiveId);
+ if (obj === null) return false;
+ if (obj.originator === viewerName) return true;
+ if (obj.assignee === viewerName) return true;
+ return obj.watchers.includes(viewerName);
+ },
+ },
+ });
+ for (const m of members.members()) {
+ files.ensureHome(m.name);
+ }
+ const { app } = createApp({
+ broker,
+ members,
+ tokens,
+ sessions,
+ teamStore: mockTeamStore(TEAM),
+ objectives,
+ files,
+ version: '0.0.0',
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ });
+ return { app, files, objectives };
+}
+
+function authed(token: string, body?: unknown, method?: string): RequestInit {
+ const init: RequestInit = {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ };
+ init.method = method ?? (body !== undefined ? 'POST' : 'GET');
+ if (body !== undefined) init.body = JSON.stringify(body);
+ return init;
+}
+
+async function uploadToHome(
+ app: ReturnType['app'],
+ token: string,
+ path: string,
+ body: string,
+): Promise {
+ const res = await app.request(
+ `/fs/write?path=${encodeURIComponent(path)}&mime=${encodeURIComponent('text/plain')}`,
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ body,
+ },
+ );
+ expect(res.status).toBe(200);
+}
+
+describe('/objectives// namespace', () => {
+ it('mirrors create-time attachments into the namespace and points the objective there', async () => {
+ const { app } = makeApp();
+ // Bob uploads a spec to his home, then creates an objective with it.
+ await uploadToHome(app, BOB, '/bob/specs/payment.md', '# Payment service\n');
+ const res = await app.request(
+ '/objectives',
+ authed(BOB, {
+ title: 'Ship payment service',
+ outcome: 'PR merged',
+ body: '',
+ assignee: 'carol',
+ attachments: [
+ { path: '/bob/specs/payment.md', name: 'payment.md', size: 1, mimeType: 'text/plain' },
+ ],
+ }),
+ );
+ expect(res.status).toBe(200);
+ const obj = (await res.json()) as Objective;
+ expect(obj.attachments).toHaveLength(1);
+ // The objective's attachment now lives in the namespace.
+ expect(obj.attachments[0]?.path).toBe(`/objectives/${obj.id}/payment.md`);
+ // Bob's home copy is untouched — the original is still readable from there.
+ const fromHome = await app.request(
+ `/fs/stat?path=${encodeURIComponent('/bob/specs/payment.md')}`,
+ authed(BOB),
+ );
+ expect(fromHome.status).toBe(200);
+ });
+
+ it('lets every objective member read AND write under the namespace, and 403s non-members', async () => {
+ const { app } = makeApp();
+ await uploadToHome(app, BOB, '/bob/notes.txt', 'context');
+ const createRes = await app.request(
+ '/objectives',
+ authed(BOB, {
+ title: 'Investigate flake',
+ outcome: 'root cause + fix',
+ body: '',
+ assignee: 'carol',
+ watchers: ['alice'],
+ attachments: [
+ { path: '/bob/notes.txt', name: 'notes.txt', size: 1, mimeType: 'text/plain' },
+ ],
+ }),
+ );
+ const obj = (await createRes.json()) as Objective;
+ const namespacePath = `/objectives/${obj.id}/notes.txt`;
+
+ // Originator (bob), assignee (carol), watcher (alice — also admin),
+ // each can read.
+ for (const tok of [BOB, CAROL, ALICE]) {
+ const r = await app.request(
+ `/fs/stat?path=${encodeURIComponent(namespacePath)}`,
+ authed(tok),
+ );
+ expect(r.status).toBe(200);
+ }
+ // Non-member dave gets 403.
+ const denied = await app.request(
+ `/fs/stat?path=${encodeURIComponent(namespacePath)}`,
+ authed(DAVE),
+ );
+ expect(denied.status).toBe(403);
+
+ // Carol (assignee) can write a follow-up file into the namespace.
+ const writeRes = await app.request(
+ `/fs/write?path=${encodeURIComponent(`/objectives/${obj.id}/findings.md`)}&mime=${encodeURIComponent('text/markdown')}`,
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${CAROL}` },
+ body: '# findings',
+ },
+ );
+ expect(writeRes.status).toBe(200);
+
+ // Dave still can't write into the namespace.
+ const writeDenied = await app.request(
+ `/fs/write?path=${encodeURIComponent(`/objectives/${obj.id}/sneaky.md`)}&mime=${encodeURIComponent('text/markdown')}`,
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${DAVE}` },
+ body: 'should fail',
+ },
+ );
+ expect(writeDenied.status).toBe(403);
+ });
+
+ it('drops namespace read access for a watcher the moment they are removed', async () => {
+ const { app } = makeApp();
+ // Set up an objective with dave as a watcher, plus an attachment
+ // mirrored into the namespace.
+ await uploadToHome(app, BOB, '/bob/draft.md', 'draft');
+ const create = await app.request(
+ '/objectives',
+ authed(BOB, {
+ title: 'Watcher-revoke check',
+ outcome: 'verified',
+ body: '',
+ assignee: 'carol',
+ watchers: ['dave'],
+ attachments: [
+ { path: '/bob/draft.md', name: 'draft.md', size: 1, mimeType: 'text/markdown' },
+ ],
+ }),
+ );
+ const obj = (await create.json()) as Objective;
+ const namespacePath = `/objectives/${obj.id}/draft.md`;
+
+ // Dave is a watcher → can read the namespace file.
+ const beforeRemove = await app.request(
+ `/fs/stat?path=${encodeURIComponent(namespacePath)}`,
+ authed(DAVE),
+ );
+ expect(beforeRemove.status).toBe(200);
+
+ // Remove dave from watchers.
+ const watchers = await app.request(
+ `/objectives/${obj.id}/watchers`,
+ authed(BOB, { remove: ['dave'] }),
+ );
+ expect(watchers.status).toBe(200);
+
+ // Access should drop immediately — the namespace ACL consults
+ // live membership, no grant cleanup needed.
+ const afterRemove = await app.request(
+ `/fs/stat?path=${encodeURIComponent(namespacePath)}`,
+ authed(DAVE),
+ );
+ expect(afterRemove.status).toBe(403);
+ });
+});
diff --git a/apps/server/test/helpers/test-stores.ts b/apps/server/test/helpers/test-stores.ts
index de23098..87b0ee3 100644
--- a/apps/server/test/helpers/test-stores.ts
+++ b/apps/server/test/helpers/test-stores.ts
@@ -31,7 +31,12 @@ export function mockTeamStore(team: Team): TeamStore {
hasTeam: () => true,
getPresets: () => ({ ...current.permissionPresets }),
setTeam: (input) => {
- current = { ...current, name: input.name, directive: input.directive, brief: input.brief };
+ current = {
+ ...current,
+ name: input.name,
+ directive: input.directive,
+ context: input.context,
+ };
return snapshot();
},
updateTeam: (patch) => {
@@ -39,7 +44,7 @@ export function mockTeamStore(team: Team): TeamStore {
...current,
name: patch.name ?? current.name,
directive: patch.directive ?? current.directive,
- brief: patch.brief ?? current.brief,
+ context: patch.context ?? current.context,
};
return snapshot();
},
@@ -86,7 +91,7 @@ export interface SeededStores {
export interface SeedTeamInput {
name: string;
directive: string;
- brief?: string;
+ context?: string;
permissionPresets?: Record;
}
@@ -96,7 +101,7 @@ export function seedStores(input: { team: SeedTeamInput; members: SeedMember[] }
stores.team.setTeam({
name: input.team.name,
directive: input.team.directive,
- brief: input.team.brief ?? '',
+ context: input.team.context ?? '',
});
for (const [name, leaves] of Object.entries(input.team.permissionPresets ?? {})) {
stores.team.setPreset(name, leaves);
diff --git a/apps/server/test/https.test.ts b/apps/server/test/https.test.ts
index 972b813..ca15d8b 100644
--- a/apps/server/test/https.test.ts
+++ b/apps/server/test/https.test.ts
@@ -28,7 +28,7 @@ const OP_TOKEN = 'ac7_https_test_operator_token';
const TEAM: Team = {
name: 'demo-team',
directive: 'Verify the HTTPS surface.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/members-endpoints.test.ts b/apps/server/test/members-endpoints.test.ts
index df75f4b..852c421 100644
--- a/apps/server/test/members-endpoints.test.ts
+++ b/apps/server/test/members-endpoints.test.ts
@@ -24,7 +24,7 @@ const AGENT_TOKEN = 'ac7_members_test_agent_token';
const TEAM: Team = {
name: 'members-team',
directive: 'Exercise member CRUD.',
- brief: '',
+ context: '',
permissionPresets: {
admin: [
'team.manage',
diff --git a/apps/server/test/objectives-endpoints.test.ts b/apps/server/test/objectives-endpoints.test.ts
index 4e9f32b..3750119 100644
--- a/apps/server/test/objectives-endpoints.test.ts
+++ b/apps/server/test/objectives-endpoints.test.ts
@@ -38,7 +38,7 @@ const DAVE = 'ac7_test_dave_secret_token';
const TEAM: Team = {
name: 'demo-team',
directive: 'Ship the thing.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/platform-connect.test.ts b/apps/server/test/platform-connect.test.ts
index 2d4dfb0..5622e05 100644
--- a/apps/server/test/platform-connect.test.ts
+++ b/apps/server/test/platform-connect.test.ts
@@ -29,7 +29,7 @@ const OP_TOKEN = 'ac7_platform_connect_op_token';
const TEAM: Team = {
name: 'demo-team',
directive: 'Exercise the platform-connect handshake.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/presence-busy.test.ts b/apps/server/test/presence-busy.test.ts
index b4e92ec..15e8604 100644
--- a/apps/server/test/presence-busy.test.ts
+++ b/apps/server/test/presence-busy.test.ts
@@ -28,7 +28,7 @@ const AGENT_TOKEN = 'ac7_busy_test_agent_token';
const TEAM: Team = {
name: 'busy-test',
directive: 'Verify busy presence flow.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/push.test.ts b/apps/server/test/push.test.ts
index 6ba113a..ea7ba7e 100644
--- a/apps/server/test/push.test.ts
+++ b/apps/server/test/push.test.ts
@@ -73,7 +73,7 @@ const BOT_TOKEN = 'ac7_push_test_bot_token';
const TEAM: Team = {
name: 'demo-team',
directive: 'Test push notifications.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/run-wiring.test.ts b/apps/server/test/run-wiring.test.ts
index 78b8a2f..463635b 100644
--- a/apps/server/test/run-wiring.test.ts
+++ b/apps/server/test/run-wiring.test.ts
@@ -27,7 +27,7 @@ const ADMIN_TOKEN = 'ac7_run_wiring_test_admin_token';
const TEAM = {
name: 'demo-team',
directive: 'Verify run.ts wiring.',
- brief: '',
+ context: '',
};
const dirsToClean: string[] = [];
diff --git a/apps/server/test/session-online.test.ts b/apps/server/test/session-online.test.ts
index 3b3b93f..c5db4d0 100644
--- a/apps/server/test/session-online.test.ts
+++ b/apps/server/test/session-online.test.ts
@@ -76,7 +76,7 @@ const ADMIN_TOKEN = 'ac7_session_online_test_admin_token';
const TEAM: Team = {
name: 'session-online-team',
directive: 'Verify the session-online notice is gated by auth plane.',
- brief: '',
+ context: '',
permissionPresets: {},
};
diff --git a/apps/server/test/shutdown.test.ts b/apps/server/test/shutdown.test.ts
index 506b5dd..461bcf4 100644
--- a/apps/server/test/shutdown.test.ts
+++ b/apps/server/test/shutdown.test.ts
@@ -20,10 +20,10 @@ import { type RunningServer, runServer } from '../src/run.js';
import { seedStores } from './helpers/test-stores.js';
const OP_TOKEN = 'ac7_shutdown_test_op';
-const TEAM: Pick = {
+const TEAM: Pick = {
name: 'shutdown-test-team',
directive: 'Verify shutdown does not hang on live SSE subscribers.',
- brief: '',
+ context: '',
};
describe('runServer shutdown with live SSE subscriber', () => {
diff --git a/apps/server/test/wizard.test.ts b/apps/server/test/wizard.test.ts
index 949755a..06a3eac 100644
--- a/apps/server/test/wizard.test.ts
+++ b/apps/server/test/wizard.test.ts
@@ -67,14 +67,14 @@ describe('runFirstRunWizard', () => {
};
}
- // Happy-path script: team name (default), directive, brief (skip),
+ // Happy-path script: team name (default), directive, context (skip),
// admin name (default), role title (default), role description (skip),
// press enter after token banner, TOTP code.
function happyScript(code: string, overrides: Partial> = {}): string[] {
return [
overrides.teamName ?? '',
overrides.directive ?? 'Ship the payment service',
- overrides.brief ?? '',
+ overrides.context ?? '',
overrides.adminName ?? '',
overrides.roleTitle ?? '',
overrides.roleDescription ?? '',
@@ -91,7 +91,7 @@ describe('runFirstRunWizard', () => {
expect(result.team.name).toBe('my-team');
expect(result.team.directive).toBe('Ship the payment service');
- expect(result.team.brief).toBe('');
+ expect(result.team.context).toBe('');
expect(result.team.permissionPresets).toBeDefined();
expect(result.admin.name).toBe('director-1');
@@ -128,7 +128,7 @@ describe('runFirstRunWizard', () => {
const io = mockIO([
'', // team name
'Ship', // directive
- '', // brief
+ '', // context
'has spaces', // bad name, rejected
'chief', // good name
'', // role title (default)
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 8125ee9..e937303 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -143,6 +143,12 @@ export default defineConfig({
},
server: {
port: 5173,
+ // Bind on all interfaces so on-LAN / tailnet devices (iPhone via
+ // Tailscale, etc.) can reach the dev server for cross-device
+ // testing. The dev server is only ever expected to run inside a
+ // trusted network — not behind a public IP — so the wider bind
+ // is the right default for an internal tool.
+ host: true,
proxy: deriveProxyRules(),
},
});
diff --git a/docs/architecture.mdx b/docs/architecture.mdx
index 07ae343..fa5579c 100644
--- a/docs/architecture.mdx
+++ b/docs/architecture.mdx
@@ -119,7 +119,7 @@ action gates on a specific leaf:
| Permission | What it permits |
|---|---|
-| `team.manage` | Edit team directive / brief / presets |
+| `team.manage` | Edit team directive / context / presets |
| `members.manage` | Create / update / delete members; rotate any token; reassign objectives |
| `objectives.create` | Create + assign objectives |
| `objectives.cancel` | Cancel any non-terminal (originator-bypass) |
diff --git a/docs/concepts/files.mdx b/docs/concepts/files.mdx
new file mode 100644
index 0000000..a662293
--- /dev/null
+++ b/docs/concepts/files.mdx
@@ -0,0 +1,286 @@
+---
+title: Files
+description: ac7's virtual filesystem — per-member homes, objective namespaces, content-addressed blob storage, and the membership-derived ACL that knits attachments into the rest of the team's primitives.
+section: Concepts
+order: 35
+---
+
+# Files
+
+ac7 has a virtual filesystem built into the broker. It's how
+agents and operators upload, share, and reference binary
+artifacts — specs, screenshots, exported reports, build outputs —
+without standing up a separate object store.
+
+The shape is intentionally small:
+
+- **Two top-level scopes**: per-member homes (`//...`) and
+ per-objective namespaces (`/objectives//...`).
+- **Content-addressed blob store** under the metadata layer.
+ Identical bytes are stored once and referenced by hash; refcount
+ tracking drops them from disk when the last entry goes away.
+- **Membership-derived ACL** for objective namespaces, plus
+ per-member ownership for homes and an explicit `fs_grants`
+ table for cross-scope shares.
+
+This doc explains the model. For wire-level details see the
+[REST API reference](/reference/rest-api), and for the agent-side
+tool surface see [MCP tools](/reference/mcp-tools).
+
+## The path model
+
+Paths are absolute, Unix-like, with `/` as separator. Segments
+allow alphanumerics, dot, underscore, hyphen, and single spaces;
+`.`/`..` traversal is rejected; no leading or trailing whitespace.
+
+The first segment classifies the scope:
+
+| Path | Scope | Owner column |
+| ----------------------------- | -------------------- | ------------------- |
+| `/` | Synthetic root | (no DB row) |
+| `/` | A member's home dir | `` |
+| `//...` | Inside that home | `` |
+| `/objectives` | Namespace parent dir | `objectives` |
+| `/objectives/` | A specific objective | `obj:` |
+| `/objectives//...` | Files in that objective | `obj:` |
+
+Root has no DB row — listing it is a synthetic operation that
+returns the top-level directories the viewer can see (their own
+home, every home if they're an admin).
+
+## Two scopes, two ACL stories
+
+### Member homes
+
+Every member gets a home at `//`. Inside their home they have
+full read, write, mkdir, move, and delete authority. Other members
+can't see into someone else's home unless one of three things is
+true:
+
+1. They're an admin (`members.manage` permission). Admins read,
+ write, and delete anywhere.
+2. They hold a **grant** for an exact path (see "Sharing across
+ scopes" below).
+3. The path is in an objective namespace they're a member of.
+
+Homes are auto-created at member creation time and re-asserted at
+broker boot, so a fresh agent can list `//` immediately
+without a write-first dance.
+
+### Objective namespaces
+
+Every objective has its own writable scope at `/objectives//`.
+The ACL is **membership-derived**: any of the originator, the
+current assignee, or any watcher gets full read, write, mkdir,
+move, and delete authority within the namespace. Add a watcher,
+they gain access; remove a watcher, they lose it. No grant rows
+need to be touched.
+
+This is the right scope for files that *belong to* the work, not
+to whoever happened to upload them. A spec, a draft PR diff, a
+captured logfile, the deliverable itself — these live with the
+objective so that:
+
+- Deleting the uploader's home copy doesn't break the objective.
+- Reassignment carries access automatically.
+- Files don't get orphaned when an agent is decommissioned.
+
+The `/objectives` parent directory exists as a routing point but
+isn't itself meaningfully writable — only the per-objective
+subdirectories underneath it are. Non-admin members can't list
+`/objectives` directly; they navigate to their specific
+`/objectives//` from the objective's detail view.
+
+## Storage layer
+
+Underneath the metadata, ac7 stores file contents in a
+content-addressed `BlobStore`. The flow on a write:
+
+1. The bytes get hashed (SHA-256) and written to disk under the
+ hash.
+2. A row in `fs_entries` records the path, kind, size, mime type,
+ owner, and `content_hash` — a pointer to the blob.
+3. `fs_blobs` tracks `(hash → refcount)`. Refcount goes up by one
+ per `fs_entries` row that points at the hash.
+
+On delete, the entry row is removed and the blob's refcount
+drops. When refcount hits zero the bytes are evicted from disk.
+
+This means **identical files are stored once**. If two members
+upload the same PDF, or an objective namespace mirrors a member's
+home file, only one copy of the bytes lives on disk — the metadata
+rows just both point at the same hash. Refcount-aware copy is
+how attachment mirroring works: the namespace entry shares the
+home entry's hash, so the bytes aren't duplicated even though both
+rows are independently deletable.
+
+## Attachment lifecycle
+
+Files become attachments when they're referenced from a message,
+an objective, or a discussion post. Each path produces a
+canonical `Attachment` record:
+
+```typescript
+interface Attachment {
+ path: string;
+ name: string;
+ size: number;
+ mimeType: string;
+}
+```
+
+The server re-derives `name`, `size`, and `mimeType` from the
+underlying entry on every attach call so the request payload
+can't lie about what's being shared. The ACL effects depend on
+which surface the file is attached to.
+
+### Objective attachments (create-time)
+
+When an objective is created with `attachments: [...]`, the
+server **mirrors each file into the objective's namespace** by
+blob-ref:
+
+1. Validate the originator can read each claimed path.
+2. For each one, create a new entry at
+ `/objectives//` pointing at the same content
+ hash. Refcount goes up; bytes don't.
+3. Rewrite the objective's `attachments` array to point at the
+ namespace paths.
+
+After this step, the objective owns its files in the literal
+sense: they live in `/objectives//`, governed by the
+membership ACL. The originator's home copy stays put, untouched
+— they can delete it later without breaking the objective.
+
+If a mirror fails (transient FS error, source goes missing
+between validation and copy), the whole create surfaces the error
+to the caller — there's no half-mirrored fallback that would leave
+the objective with a mix of namespace and pointer paths. The
+caller retries.
+
+### Discussion-message attachments
+
+Posts to an objective's `obj:` discussion thread can carry
+attachments too, but those follow the **message attachment** path
+(grants with `granted_via = 'msg:'`), not the namespace
+mirror. Discussion attachments are conversational artifacts, not
+deliverables — the namespace stays focused on files that
+represent the work itself.
+
+### Direct writes into an objective namespace
+
+Members of an objective can also write into the namespace
+directly with `fs_write /objectives//`. No separate
+"attach" call is needed — the file is in the namespace and
+visible to every member by virtue of the ACL. The objective's
+`attachments` JSON column doesn't auto-update on direct writes;
+it stays the canonical "create-time deliverables" list. Direct
+writes are scratch / collaboration artifacts.
+
+### Watcher membership changes
+
+Watcher add/remove has no FS-side bookkeeping: namespace
+attachments are gated by `isObjectiveMember` at read time, so
+adding a watcher grants access at the moment the membership lands
+and removing one revokes it the moment they're gone. No grant
+rows to backfill or sweep.
+
+## Sharing across scopes — the grant table
+
+The `fs_grants` table records `(path, viewer, granted_via)`
+triples. It's how a file in one scope becomes readable from
+another. Grants are written when a message is posted with an
+attachment — `granted_via = ''` — so every recipient
+of the post gets read access to the file the sender attached.
+That's the only path that produces grant rows today; objective
+namespace access flows through the membership ACL instead, no
+grants needed.
+
+Grants are **read-only** by design. There's no "grant write" — if
+a teammate needs to edit a file with you, the right move is to
+create a shared scope (objective namespace, eventually a channel
+namespace) that both of you are members of, not to hand out
+write grants on a personal home file.
+
+Grants are dropped automatically when:
+
+- The granted path is deleted (cascade in `fs_rm`).
+- The granted path is moved (the move clears and reissues grants
+ at the new path).
+
+### Discovery
+
+Two read-only views surface what a viewer can see beyond their
+own home:
+
+- **`/fs/shared`** — every file shared with the caller via any
+ grant, deduped by path. Owned by the agent's `fs_shared` tool
+ and the web UI's "Shared with me" view.
+- **`/fs/all`** — admin-only flat list of every file across every
+ home, newest-first. The web UI surfaces this as the "All files"
+ tab in the Files panel for members with `members.manage`. Not
+ exposed as an agent tool — admin agents already have full
+ navigation authority via `fs_ls /`.
+
+## Permissions summary
+
+For a path `P` and viewer `V`, read access requires:
+
+1. `V` has `members.manage` (admin), OR
+2. `V` is the owner of `P` (`ownerOf(P) === V.name`), OR
+3. `P` is under an objective namespace `V` is a member of, OR
+4. `V` has a grant for `P`.
+
+Write access drops rule 4 — grants are read-only:
+
+1. `V` has `members.manage`, OR
+2. `V` is the owner of `P`, OR
+3. `P` is under an objective namespace `V` is a member of.
+
+Move and delete follow the write rules. Listing a directory
+follows the read rules of that directory.
+
+The full permission model lives in
+[Permissions](/concepts/permissions); files only ever consult
+`members.manage` from that surface.
+
+## Operator and agent surfaces
+
+Agents interact with the FS through MCP tools — `fs_ls`,
+`fs_stat`, `fs_read`, `fs_write`, `fs_mkdir`, `fs_rm`, `fs_mv`,
+`fs_shared`. The full schema is in
+[MCP tools](/reference/mcp-tools); each tool's description
+mentions which scopes it applies to.
+
+Operators see files via the web UI's Files panel, which mounts
+the same `/fs/*` HTTP surface as the agents. Three modes:
+
+- **Tree** — navigate the path tree starting at root. The viewer
+ sees their own home; admins see every home.
+- **Shared with me** — flat list backed by `/fs/shared`. Files
+ attached to threads the viewer participates in.
+- **All files** *(admin only)* — flat list backed by `/fs/all`.
+ Every file across every home and namespace, newest-first.
+
+There is no single CLI verb for files; the web UI and the agent
+tools cover the cases. For one-off operator scripting, the
+[REST API](/reference/rest-api#filesystem) endpoints can be
+called directly with the operator's token.
+
+## What's next
+
+The current model covers per-member homes and per-objective
+namespaces. Two natural extensions follow the same membership-
+derived pattern:
+
+- **`/team/...`** — a team-wide writable scope, governed by team
+ membership. Lets the team share files that aren't tied to a
+ specific objective.
+- **`/channels//...`** — per-channel scopes, governed by
+ channel membership. Replaces "drop the file in your home and
+ attach it" with "save the file in the channel."
+
+Both reuse the same primitives proven by the objective namespace
+— a non-individual owner, a membership-driven ACL, mirroring at
+the attach point. Neither is shipped yet; this section will
+update when they are.
diff --git a/docs/concepts/permissions.mdx b/docs/concepts/permissions.mdx
index 2bd0468..c6d3529 100644
--- a/docs/concepts/permissions.mdx
+++ b/docs/concepts/permissions.mdx
@@ -24,7 +24,7 @@ touch other members or shape the team itself.
| Permission | What it permits |
|---|---|
-| **`team.manage`** | Edit `team.directive`, `team.brief`, and `team.permissionPresets`. |
+| **`team.manage`** | Edit `team.directive`, `team.context`, and `team.permissionPresets`. |
| **`members.manage`** | Create / update / delete members. Rotate any member's bearer token. Approve / reject pending enrollments. Add / remove channel members. Reassign any objective. |
| **`objectives.create`** | Create objectives and assign them to any teammate. The originator is stamped as the caller. |
| **`objectives.cancel`** | Cancel any non-terminal objective. (Originators can always cancel their own.) |
diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx
index 873f22f..b1a6223 100644
--- a/docs/reference/config.mdx
+++ b/docs/reference/config.mdx
@@ -32,7 +32,7 @@ broker's SQLite.
"team": {
"name": "platform-eng",
"directive": "Ship the v2 ingest pipeline by Q3",
- "brief": "Multi-paragraph context...",
+ "context": "Multi-paragraph team context...",
"permissionPresets": {
"admin": ["team.manage", "members.manage", "objectives.create",
"objectives.cancel", "objectives.reassign",
diff --git a/docs/reference/rest-api.mdx b/docs/reference/rest-api.mdx
index a82e6f9..cac94f1 100644
--- a/docs/reference/rest-api.mdx
+++ b/docs/reference/rest-api.mdx
@@ -64,7 +64,7 @@ Liveness probe. Cheap; always responds 200 if the process is up.
The team-context packet. Returns the caller's full member record
(name, role, instructions, permissions) plus the team
-(name, directive, brief, permissionPresets), the public projection
+(name, directive, context, permissionPresets), the public projection
of every teammate, and the caller's currently-open objectives.
The runner caches this at startup and uses it for the entire
diff --git a/docs/runners/overview.mdx b/docs/runners/overview.mdx
index 40058a9..e656beb 100644
--- a/docs/runners/overview.mdx
+++ b/docs/runners/overview.mdx
@@ -61,7 +61,7 @@ Concretely, on every runner startup:
1. **Authenticate** with `--token` / `$AC7_TOKEN` /
`~/.config/ac7/auth.json`.
2. **Fetch the briefing** — the member's name, role, permissions,
- teammates, team directive + brief, and currently-open objectives.
+ teammates, team directive + context, and currently-open objectives.
3. **Bind the IPC socket** at
`$TMPDIR/.ac7-runner-.sock` (overridable). The agent's MCP
bridge subprocess connects back to this.
diff --git a/packages/cli/src/commands/auth-config.ts b/packages/cli/src/commands/auth-config.ts
index 8c6fc4c..3794622 100644
--- a/packages/cli/src/commands/auth-config.ts
+++ b/packages/cli/src/commands/auth-config.ts
@@ -1,12 +1,25 @@
/**
* Client-side auth config — `(broker URL, bearer token)` persisted at
- * `~/.config/ac7/auth.json` after a successful `ac7 connect` run.
+ * `./.ac7/auth.json` (project-scoped, sibling to `ac7.json`) after a
+ * successful `ac7 connect` run.
+ *
+ * Project-scoped (not user-scoped) so a single machine can hold a
+ * distinct member identity per agent workspace — running `ac7 connect`
+ * in `~/projects/agent-a/` and `~/projects/agent-b/` produces two
+ * independent enrollments without one stomping the other.
+ *
+ * Lookup uses git-style walk-up: starting at cwd, we walk parents
+ * until a `.ac7/auth.json` is found, so any `ac7` command run from a
+ * subdirectory of an enrolled project picks up the right token. Saves
+ * also walk up first — re-running `ac7 connect` from a subfolder
+ * updates the project's existing entry instead of creating a stray
+ * config in the wrong place. Only when nothing is found does the save
+ * land in cwd.
*
* The CLI defaults to the env-var path (`AC7_URL` / `AC7_TOKEN`)
* when both are present, and falls back to this file when they're
- * absent. That keeps existing CI / scripted setups working as-is
- * while giving operators the gh-style "log in once" experience for
- * machine-issued tokens.
+ * absent. `AC7_AUTH_CONFIG_PATH` overrides everything (used by tests
+ * and air-gapped layouts).
*
* Storage shape is intentionally minimal — one entry per broker
* URL, the most-recent write wins for the same URL. We do NOT
@@ -19,9 +32,8 @@
* `ac7.json` and the telemetry path.
*/
-import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import { homedir } from 'node:os';
-import { dirname, join } from 'node:path';
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, join, parse as parsePath, resolve } from 'node:path';
export interface AuthConfigEntry {
url: string;
@@ -36,30 +48,56 @@ interface AuthConfigFile {
entries: AuthConfigEntry[];
}
+/** The directory + filename used at every level of the walk-up search. */
+const AUTH_DIR = '.ac7';
+const AUTH_FILE = 'auth.json';
+
/**
- * Resolve the auth-config file path. `AC7_AUTH_CONFIG_PATH` overrides
- * for tests and air-gapped layouts.
+ * The path where a fresh `ac7 connect` writes when no existing config
+ * is discovered upward — always cwd-scoped. `AC7_AUTH_CONFIG_PATH`
+ * overrides for tests and air-gapped layouts.
*/
export function authConfigPath(): string {
const override = process.env.AC7_AUTH_CONFIG_PATH;
if (override) return override;
- const home = homedir();
- if (process.platform === 'win32') {
- const appdata = process.env.APPDATA ?? join(home, 'AppData', 'Roaming');
- return join(appdata, 'ac7', 'auth.json');
- }
- if (process.platform === 'darwin') {
- return join(home, 'Library', 'Application Support', 'ac7', 'auth.json');
+ return join(process.cwd(), AUTH_DIR, AUTH_FILE);
+}
+
+/**
+ * Walk up from `start` (default cwd) looking for the closest
+ * `.ac7/auth.json`. Returns its path if found, otherwise null.
+ * Stops at the filesystem root. The env override short-circuits the
+ * walk so test sandboxes stay isolated.
+ */
+export function findAuthConfigPath(start: string = process.cwd()): string | null {
+ const override = process.env.AC7_AUTH_CONFIG_PATH;
+ if (override) return existsSync(override) ? override : null;
+ let dir = resolve(start);
+ const root = parsePath(dir).root;
+ // `root` itself is a valid place to look — git puts no `.git` at `/`
+ // by convention, but we don't impose that, so include it.
+ while (true) {
+ const candidate = join(dir, AUTH_DIR, AUTH_FILE);
+ if (existsSync(candidate)) return candidate;
+ if (dir === root) return null;
+ const parent = dirname(dir);
+ if (parent === dir) return null;
+ dir = parent;
}
- const xdg = process.env.XDG_CONFIG_HOME ?? join(home, '.config');
- return join(xdg, 'ac7', 'auth.json');
}
-/** Read every saved entry. Empty list on missing file or unrecognized shape. */
-export function loadAuthConfig(path: string = authConfigPath()): AuthConfigFile {
+/**
+ * Read every saved entry from the closest project-scoped config
+ * found via walk-up. Empty list on no file or unrecognized shape.
+ * Pass `path` to read from a specific file (used by tests and the
+ * `--auth-config` flag).
+ */
+export function loadAuthConfig(path?: string): AuthConfigFile {
+ const resolved = path ?? findAuthConfigPath();
+ if (resolved === null) return { schema: 1, entries: [] };
let raw: string;
try {
- raw = readFileSync(path, 'utf8');
+ raw = readFileSync(resolved, 'utf8');
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return { schema: 1, entries: [] };
@@ -92,25 +130,30 @@ export function loadAuthConfig(path: string = authConfigPath()): AuthConfigFile
* Atomically replace the entry for `url` (or insert if new) and
* write back at 0o600. `mkdir -p` the containing dir at 0o700 so a
* fresh install can save without an explicit setup step.
+ *
+ * Without an explicit `path`, save targets an existing project-level
+ * config found via walk-up (so re-running `ac7 connect` from a
+ * subdirectory updates the project's config rather than scattering
+ * a new `.ac7/` next to wherever the operator happens to be). Falls
+ * back to the cwd-scoped path when no existing config is found.
*/
-export function saveAuthEntry(entry: AuthConfigEntry, path: string = authConfigPath()): void {
- const file = loadAuthConfig(path);
+export function saveAuthEntry(entry: AuthConfigEntry, path?: string): void {
+ const target = path ?? findAuthConfigPath() ?? authConfigPath();
+ const file = loadAuthConfig(target);
const next: AuthConfigEntry[] = file.entries.filter((e) => e.url !== entry.url);
next.push(entry);
const out: AuthConfigFile = { schema: 1, entries: next };
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
- writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 });
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
+ writeFileSync(target, `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 });
}
/**
* Find a saved token for `url` (exact match — we do not normalize
- * trailing slashes here; the SDK Client does). Returns null if no
- * entry matches.
+ * trailing slashes here; the SDK Client does). Walks up from cwd to
+ * find the closest project-scoped config. Returns null if no entry
+ * matches.
*/
-export function findAuthEntry(
- url: string,
- path: string = authConfigPath(),
-): AuthConfigEntry | null {
+export function findAuthEntry(url: string, path?: string): AuthConfigEntry | null {
const file = loadAuthConfig(path);
return file.entries.find((e) => e.url === url) ?? null;
}
diff --git a/packages/cli/src/commands/connect.ts b/packages/cli/src/commands/connect.ts
index fa3beac..f0447bb 100644
--- a/packages/cli/src/commands/connect.ts
+++ b/packages/cli/src/commands/connect.ts
@@ -4,13 +4,16 @@
* Replaces the "create a member, copy the token, paste it into your
* config" onboarding flow with the gh-auth-style two-leg handshake:
*
+ * 0. Prompt for the broker URL when neither `--url` nor `AC7_URL`
+ * was provided (default `http://127.0.0.1:8717`). A bare Enter
+ * accepts the default.
* 1. CLI POSTs /enroll → broker mints (deviceCode, userCode)
* 2. CLI prints `userCode` + verification URL to the operator,
* then polls /enroll/poll every `interval` seconds
* 3. Director, signed in via TOTP at the broker URL, types the
* code into the SPA and approves
* 4. CLI's next poll resolves with the token; CLI persists it to
- * `~/.config/ac7/auth.json` and exits
+ * `./.ac7/auth.json` (project-scoped) and exits
*
* The bearer token plaintext is never echoed to either operator's
* terminal scrollback — it goes straight from the broker to the CLI's
@@ -24,9 +27,10 @@
* director exists yet to approve).
*/
+import { relative } from 'node:path';
import { Client, ClientError } from '@agentc7/sdk/client';
import { DEFAULT_PORT, ENV } from '@agentc7/sdk/protocol';
-import { authConfigPath, saveAuthEntry } from './auth-config.js';
+import { authConfigPath, findAuthConfigPath, saveAuthEntry } from './auth-config.js';
import { UsageError } from './errors.js';
export { UsageError };
@@ -52,6 +56,13 @@ export interface ConnectCommandInput {
fetch?: typeof fetch;
/** Test-only clock injection. */
now?: () => number;
+ /**
+ * Prompt the operator for a single line of input. Used to ask for
+ * the broker URL when neither `--url` nor `AC7_URL` was provided.
+ * Returns the raw answer (no trim). Tests inject this; in production
+ * we wire up a readline-backed default.
+ */
+ prompt?: (question: string) => Promise;
}
export interface ConnectCommandOutput {
@@ -108,9 +119,20 @@ export async function runConnectCommand(
stderr: (line: string) => void,
abortSignal?: AbortSignal,
): Promise {
- const url = input.url ?? process.env[ENV.url] ?? `http://127.0.0.1:${DEFAULT_PORT}`;
+ // `--url` flag and `AC7_URL` env both bypass the prompt — they're
+ // explicit "use this URL" signals (CI, scripts, sticky shell env).
+ // Only with neither do we run the wizard; a bare Enter accepts the
+ // local-broker default. The banner that follows shows the URL so
+ // the operator still gets eyes-on confirmation before approving.
+ const defaultUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
+ let url = input.url ?? process.env[ENV.url];
+ if (!url) {
+ const ask = input.prompt ?? defaultPrompt;
+ const answer = (await ask(`Broker URL [${defaultUrl}]: `)).trim();
+ url = answer.length > 0 ? answer : defaultUrl;
+ }
if (!/^https?:\/\//.test(url)) {
- throw new UsageError(`connect: --url must be http(s)://… (got '${url}')`);
+ throw new UsageError(`connect: URL must be http(s)://… (got '${url}')`);
}
// Use the SDK Client with skipAuth on the calls that need it.
@@ -239,7 +261,7 @@ export async function runConnectCommand(
stdout(` token: ${data.token}`);
} else {
stdout(` saved to: ${authConfigDisplayPath(input.authConfigPath)}`);
- stdout(` next: ac7 claude-code`);
+ stdout(` next: ac7 claude-code (or: ac7 codex)`);
}
return {
url,
@@ -255,6 +277,24 @@ export async function runConnectCommand(
}
}
+/**
+ * Readline-backed prompt for the broker URL. Stays inline (no shared
+ * wizard IO module) since `connect` only ever asks one question; the
+ * dynamic `node:readline` import keeps the cold-start cost off the
+ * non-interactive paths (`--url` flag / `AC7_URL` env).
+ */
+async function defaultPrompt(question: string): Promise {
+ const { createInterface } = await import('node:readline');
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
+ try {
+ return await new Promise((resolve) => {
+ rl.question(question, (answer) => resolve(answer));
+ });
+ } finally {
+ rl.close();
+ }
+}
+
/**
* Default label hint when the operator didn't pass `--label`. Prefer
* `$HOSTNAME` when set (the actual machine name); fall back to a
@@ -267,12 +307,14 @@ function defaultLabelHint(): string {
}
/**
- * Render the auth-config path for display, expanding $HOME → `~` so
- * the line in the success output reads naturally.
+ * Render the auth-config path for display, preferring a `./`-prefixed
+ * relative path when the file lives under cwd (the common case for
+ * project-scoped configs). Falls back to absolute when the file lives
+ * above cwd (re-`connect` from a subdir updating the project root).
*/
function authConfigDisplayPath(override: string | undefined): string {
- const path = override ?? authConfigPath();
- const home = process.env.HOME;
- if (home && path.startsWith(home)) return `~${path.slice(home.length)}`;
+ const path = override ?? findAuthConfigPath() ?? authConfigPath();
+ const rel = relative(process.cwd(), path);
+ if (!rel.startsWith('..') && !rel.startsWith('/')) return `./${rel}`;
return path;
}
diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts
index 2cccf27..e5a31b7 100644
--- a/packages/cli/src/commands/serve.ts
+++ b/packages/cli/src/commands/serve.ts
@@ -174,7 +174,7 @@ async function runWizardOrFail(
stores.team.setTeam({
name: wizard.team.name,
directive: wizard.team.directive,
- brief: wizard.team.brief,
+ context: wizard.team.context,
});
for (const [name, leaves] of Object.entries(wizard.team.permissionPresets)) {
stores.team.setPreset(name, leaves);
diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts
index b2c38a5..b15e5f4 100644
--- a/packages/cli/src/commands/setup.ts
+++ b/packages/cli/src/commands/setup.ts
@@ -112,7 +112,7 @@ export async function runSetupCommand(
stores.team.setTeam({
name: wizard.team.name,
directive: wizard.team.directive,
- brief: wizard.team.brief,
+ context: wizard.team.context,
});
for (const [name, leaves] of Object.entries(wizard.team.permissionPresets)) {
stores.team.setPreset(name, leaves);
diff --git a/packages/cli/src/commands/team.ts b/packages/cli/src/commands/team.ts
index 428678c..d5ab275 100644
--- a/packages/cli/src/commands/team.ts
+++ b/packages/cli/src/commands/team.ts
@@ -3,13 +3,13 @@
*
* Subcommands:
* ac7 team get
- * ac7 team set [--name ] [--directive ] [--brief ]
+ * ac7 team set [--name ] [--directive ] [--context ]
*
* Talks to the running broker via the HTTP API. Mutations require
* the calling member to have `team.manage`. Changes apply
* immediately on the server side; agents already in an MCP session
* still need a runner restart to pick up changes that flow into the
- * MCP `instructions` string (directive, brief), since that string is
+ * MCP `instructions` string (directive, context), since that string is
* frozen for the lifetime of a session by the MCP protocol.
*/
@@ -56,11 +56,11 @@ async function runGet(
}
stdout(`name ${team.name}`);
stdout(`directive ${team.directive}`);
- stdout('brief');
- if (team.brief.trim().length === 0) {
+ stdout('context');
+ if (team.context.trim().length === 0) {
stdout(' (none)');
} else {
- for (const line of team.brief.split('\n')) stdout(` ${line}`);
+ for (const line of team.context.split('\n')) stdout(` ${line}`);
}
const presetNames = Object.keys(team.permissionPresets);
stdout(`presets ${presetNames.length === 0 ? '(none)' : presetNames.join(', ')}`);
@@ -76,22 +76,22 @@ async function runSet(
options: {
name: { type: 'string' },
directive: { type: 'string' },
- brief: { type: 'string' },
- 'brief-file': { type: 'string' },
+ context: { type: 'string' },
+ 'context-file': { type: 'string' },
},
allowPositionals: false,
});
- const patch: { name?: string; directive?: string; brief?: string } = {};
+ const patch: { name?: string; directive?: string; context?: string } = {};
if (typeof values.name === 'string') patch.name = values.name;
if (typeof values.directive === 'string') patch.directive = values.directive;
- if (typeof values.brief === 'string') patch.brief = values.brief;
- if (typeof values['brief-file'] === 'string') {
+ if (typeof values.context === 'string') patch.context = values.context;
+ if (typeof values['context-file'] === 'string') {
const { readFileSync } = await import('node:fs');
- patch.brief = readFileSync(values['brief-file'], 'utf8');
+ patch.context = readFileSync(values['context-file'], 'utf8');
}
if (Object.keys(patch).length === 0) {
throw new UsageError(
- 'team set requires at least one of --name, --directive, --brief, --brief-file',
+ 'team set requires at least one of --name, --directive, --context, --context-file',
);
}
const team = await client.updateTeam(patch);
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 9ab149c..01a47f7 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -124,6 +124,48 @@ function resolveAuth(input: { url?: string; token?: string }): {
return { url, token };
}
+/**
+ * Same as `resolveAuth` but runs the device-code `ac7 connect` flow
+ * inline when no token can be resolved, returning the freshly-minted
+ * token instead of failing. Used by the long-running runner verbs
+ * (`ac7 claude-code`, `ac7 codex`) where the natural UX on first run
+ * is "set me up, then start the session" rather than bouncing the
+ * operator out to a separate command.
+ *
+ * Single-use verbs (push, roster, objectives) keep the hard fail —
+ * those are typically scripted, and prompting from the middle of a
+ * pipeline would be surprising.
+ */
+async function resolveAuthOrConnect(input: { url?: string; token?: string }): Promise<{
+ url: string;
+ token: string;
+}> {
+ const url = input.url ?? process.env[ENV.url] ?? `http://127.0.0.1:${DEFAULT_PORT}`;
+ let token = input.token ?? process.env[ENV.token];
+ if (!token) {
+ const saved = findAuthEntry(url);
+ if (saved) token = saved.token;
+ }
+ if (token) return { url, token };
+
+ // No auth in this project. Fall through to the device-code flow.
+ // Pass `input.url` through (not the resolved fallback) so connect
+ // re-runs its own resolution — that way an unset `--url` still
+ // triggers connect's own prompt path with the right default.
+ process.stdout.write('ac7: no saved auth for this directory — running `ac7 connect`...\n');
+ try {
+ const result = await runConnectCommand(
+ input.url !== undefined ? { url: input.url } : {},
+ (line) => process.stdout.write(`${line}\n`),
+ (line) => process.stderr.write(`${line}\n`),
+ );
+ return { url: result.url, token: result.token };
+ } catch (err) {
+ if (err instanceof UsageError) fail(err.message, 2);
+ fail(err instanceof Error ? err.message : String(err));
+ }
+}
+
async function main(): Promise {
const argv = process.argv.slice(2);
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
@@ -745,7 +787,7 @@ async function handleClaudeCode(args: string[]): Promise {
}
try {
- const resolved = resolveAuth({ url, token });
+ const resolved = await resolveAuthOrConnect({ url, token });
const code = await runClaudeCodeCommand({
url: resolved.url,
token: resolved.token,
@@ -820,7 +862,7 @@ async function handleCodex(args: string[]): Promise {
}
try {
- const resolved = resolveAuth({ url, token });
+ const resolved = await resolveAuthOrConnect({ url, token });
const code = await runCodexCommand({
url: resolved.url,
token: resolved.token,
diff --git a/packages/cli/src/runtime/tools.ts b/packages/cli/src/runtime/tools.ts
index b60f971..8804214 100644
--- a/packages/cli/src/runtime/tools.ts
+++ b/packages/cli/src/runtime/tools.ts
@@ -56,8 +56,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
name: 'roster',
description:
`List all teammates currently on the ac7 net. You go by ${identity} in ` +
- `team ${team.name}. Directive: ${team.directive}. Returns each teammate's ` +
- `name, role, authority, and connection state.`,
+ `team ${team.name}. Returns each teammate's name, role, authority, and ` +
+ `connection state.`,
inputSchema: { type: 'object', properties: {} },
},
{
@@ -69,12 +69,12 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
`named channel's members, use \`channels_post\` instead — \`broadcast\` always goes ` +
`to general. You go by ${identity}. Teammates: ${teammateList}. Optionally attach ` +
`files from your home (\`/${name}/...\`); recipients automatically receive read access ` +
- `to each attached path via the resulting message.`,
+ `to each attached path via the resulting message. Returns delivery counts (live ` +
+ `subscribers, addressed targets) and the new message id.`,
inputSchema: {
type: 'object',
properties: {
body: { type: 'string', description: 'The message body the team will receive.' },
- title: { type: 'string', description: 'Optional short title / subject line.' },
level: {
type: 'string',
enum: [...LEVELS],
@@ -95,14 +95,14 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
description:
`Send a direct message to a specific teammate on ${team.name}. Messages are ` +
`private to you and the target. You go by ${identity}. Available names: ` +
- `${teammateList}. Directive: ${team.directive}. Optionally attach files from ` +
- `your home; the recipient receives read access to each attached path.`,
+ `${teammateList}. Optionally attach files from your home; the recipient ` +
+ `receives read access to each attached path. Returns delivery counts and the ` +
+ `new message id.`,
inputSchema: {
type: 'object',
properties: {
to: { type: 'string', description: 'The name of the teammate to message.' },
body: { type: 'string', description: 'The message body.' },
- title: { type: 'string', description: 'Optional short title / subject line.' },
level: {
type: 'string',
enum: [...LEVELS],
@@ -138,7 +138,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
`team. You must already be a member of the channel; ask a director to add you if ` +
`not. You go by ${identity}. Optionally attach files from your home; channel members ` +
`receive read access to each attached path. To find available channels run ` +
- `\`channels_list\`. To post to the team-wide general channel use \`broadcast\`.`,
+ `\`channels_list\`. To post to the team-wide general channel use \`broadcast\`. ` +
+ `Returns delivery counts and the new message id.`,
inputSchema: {
type: 'object',
properties: {
@@ -147,7 +148,6 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
description: 'Channel slug (e.g. "frontend", "ops"). Must be a channel you belong to.',
},
body: { type: 'string', description: 'The message body.' },
- title: { type: 'string', description: 'Optional short title / subject line.' },
level: {
type: 'string',
enum: [...LEVELS],
@@ -167,11 +167,10 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
name: 'recent',
description:
`Fetch recent messages from the ${team.name} team's general channel, a specific ` +
- `DM thread, or a named channel. You go by ${identity}. Team directive: ` +
- `${team.directive}. Pass exactly one of: \`with=NAME\` for DMs with that teammate, ` +
- `\`channel=SLUG\` for a named channel's scrollback, or no scope arg for the general ` +
- `team channel. Returns messages newest-first up to ${DEFAULT_RECENT_LIMIT} by ` +
- `default (max ${MAX_RECENT_LIMIT}).`,
+ `DM thread, or a named channel. You go by ${identity}. Pass exactly one of: ` +
+ `\`with=NAME\` for DMs with that teammate, \`channel=SLUG\` for a named channel's ` +
+ `scrollback, or no scope arg for the general team channel. Returns messages ` +
+ `newest-first up to ${DEFAULT_RECENT_LIMIT} by default (max ${MAX_RECENT_LIMIT}).`,
inputSchema: {
type: 'object',
properties: {
@@ -199,7 +198,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
`assigned to you, originated by you, or objectives you're watching. ` +
`Use \`status\` to filter (active | blocked | done | cancelled); omit to see all ` +
`statuses. Objectives always carry a required outcome — use \`objectives_view\` ` +
- `for full detail including the watcher list and audit log.`,
+ `for full detail including the watcher list and audit log. Returns each objective's ` +
+ `id, title, outcome, status, assignee, originator, and timestamps.`,
inputSchema: {
type: 'object',
properties: {
@@ -215,10 +215,11 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
{
name: 'objectives_view',
description:
- `Fetch the full state of a single objective including its outcome, current status, ` +
- `block reason (if any), and the append-only event history. Use this before calling ` +
+ `Fetch the full state of a single objective. Use this before calling ` +
`\`objectives_update\` or \`objectives_complete\` so you have the latest acceptance ` +
- `criteria fresh in context.`,
+ `criteria fresh in context. Returns the full objective record (id, title, outcome, ` +
+ `body, status, assignee, originator, watchers, attachments, block reason if any, ` +
+ `result if completed) plus the append-only event log.`,
inputSchema: {
type: 'object',
properties: {
@@ -235,7 +236,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
`block. This tool is for STATE transitions only — for progress notes, questions, ` +
`intermediate findings, or any conversation about the objective, use ` +
`\`objectives_discuss\` to post into the objective's discussion thread. This tool ` +
- `never transitions to 'done' — call \`objectives_complete\` for that.`,
+ `never transitions to 'done' — call \`objectives_complete\` for that. Returns the ` +
+ `updated objective.`,
inputSchema: {
type: 'object',
properties: {
@@ -264,7 +266,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
`findings, coordination with the originator, or acknowledgments — anything that's ` +
`conversation rather than a state transition. Every post is archived alongside ` +
`the objective's event log and is visible in the web UI's inline thread view. ` +
- `Optionally attach files from your home; thread members receive automatic read access.`,
+ `Optionally attach files from your home; thread members receive automatic read access. ` +
+ `Returns the new message id.`,
inputSchema: {
type: 'object',
properties: {
@@ -273,10 +276,6 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
type: 'string',
description: 'The message body to post into the objective thread.',
},
- title: {
- type: 'string',
- description: 'Optional short title / subject line.',
- },
attachments: {
type: 'array',
items: { type: 'string' },
@@ -293,7 +292,8 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
`Mark an objective as done with a required result summary. Call ` +
`\`objectives_view\` first to refresh the acceptance criteria in context. The ` +
`\`result\` should explicitly address whether the stated outcome was met and link ` +
- `or describe the deliverable. Only the current assignee may call this.`,
+ `or describe the deliverable. Only the current assignee may call this. Returns ` +
+ `the now-completed objective with its \`completedAt\` and \`result\` filled in.`,
inputSchema: {
type: 'object',
properties: {
@@ -314,6 +314,14 @@ export function defineTools(briefing: BriefingResponse): Tool[] {
// require either a grant (the file was attached to a message you
// can see) or director authority. See `fs_shared` for a list of
// files shared with you.
+ //
+ // Objective namespaces live at `/objectives//` and are
+ // collaboratively read/write/delete-able by every member of the
+ // objective (originator + assignee + watchers). Files attached at
+ // objective-create time are mirrored into this namespace
+ // automatically — agents who are members can also `fs_write`
+ // additional files there directly, and they participate in the
+ // same membership ACL.
...buildFilesystemTools(name),
// ── UserType-gated tools ────────────────────────────────────────
//
@@ -359,31 +367,33 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] {
tools.push({
name: 'team_get',
description:
- 'Read the current team config (name, directive, brief, permission presets). ' +
- 'Use this to confirm team state before proposing edits, or to check whether ' +
- 'a previous `team_update` landed.',
+ 'Read the current team config: returns name, directive, context, and the named ' +
+ 'permission presets. Use this to confirm team state before proposing edits, or ' +
+ 'to check whether a previous `team_update` landed.',
inputSchema: { type: 'object', properties: {} },
});
tools.push({
name: 'team_update',
description:
- 'Update one or more team-level fields. `directive` and `brief` change the ' +
- 'context every member is briefed with on subsequent MCP sessions; live ' +
- 'sessions still reflect the OLD strings until the runner restarts (the MCP ' +
+ 'Update one or more team-level fields. `directive` and `context` change the ' +
+ 'briefing every member is shown on subsequent MCP sessions; live sessions ' +
+ 'still reflect the OLD strings until the runner restarts (the MCP ' +
'`instructions` field is frozen for the lifetime of a session by protocol). ' +
- 'Authority: requires `team.manage`. Pass at least one of `name`, `directive`, ' +
- '`brief`.',
+ 'Pass at least one of `name`, `directive`, `context`. Returns the updated team ' +
+ 'config (same shape as `team_get`).',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'New team name (1–128 chars).' },
directive: {
type: 'string',
- description: "New short directive (1–512 chars). Set the team's overarching mission.",
+ description:
+ "New short directive (1–512 chars). The team's standing imperative — what the team is here to do.",
},
- brief: {
+ context: {
type: 'string',
- description: 'New longer brief (≤ 4096 chars). Operating context.',
+ description:
+ 'New longer team context (≤ 4096 chars). Background paragraph that complements the directive.',
},
},
},
@@ -392,7 +402,9 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] {
// ─── Permission presets ──────────────────────────────────────
tools.push({
name: 'presets_list',
- description: "List the team's permission presets — named bundles of leaf permissions.",
+ description:
+ "List the team's permission presets — named bundles of leaf permissions. " +
+ 'Returns each preset as `{ name, permissions[] }`.',
inputSchema: { type: 'object', properties: {} },
});
tools.push({
@@ -400,7 +412,7 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] {
description:
'Create or replace a permission preset. Members that reference this preset by ' +
'name in their raw permissions automatically pick up the new leaf set on the next ' +
- 'read — no member-by-member re-resolve required.',
+ 'read — no member-by-member re-resolve required. Returns the upserted preset.',
inputSchema: {
type: 'object',
properties: {
@@ -421,9 +433,9 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] {
tools.push({
name: 'presets_delete',
description:
- 'Delete a permission preset. Returns the names of members that still reference it; ' +
- 'their resolved permissions silently drop those leaves on the next read. Use this ' +
- 'with intent — there is no soft-delete.',
+ 'Delete a permission preset. Use this with intent — there is no soft-delete. ' +
+ 'Returns the names of members that still reference the deleted preset (their ' +
+ 'resolved permissions silently drop those leaves on the next read).',
inputSchema: {
type: 'object',
properties: {
@@ -439,9 +451,10 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] {
tools.push({
name: 'members_add',
description:
- 'Create a new team member. Returns the plaintext bearer token exactly once — ' +
- 'capture it from the response and deliver it to the operator/agent securely. ' +
- '`permissions` accepts preset names (e.g. "admin", "operator") or leaf permissions.',
+ 'Create a new team member. `permissions` accepts preset names (e.g. "admin", ' +
+ '"operator") or leaf permissions. Returns the new member plus the plaintext ' +
+ 'bearer token (emitted exactly once — capture it from the response and deliver ' +
+ 'it to the operator/agent securely).',
inputSchema: {
type: 'object',
properties: {
@@ -469,7 +482,8 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] {
description:
"Update an existing member's role, instructions, or permissions. Changes to " +
"`instructions` apply to that member's NEXT MCP session — the current session " +
- 'continues to reflect the old briefing until the runner restarts.',
+ 'continues to reflect the old briefing until the runner restarts. Returns the ' +
+ 'updated member record (no token, no totp secret — those are not re-emitted).',
inputSchema: {
type: 'object',
properties: {
@@ -491,7 +505,8 @@ function buildAdminTools(briefing: BriefingResponse): Tool[] {
name: 'members_remove',
description:
'Delete a member. All bearer tokens for the member are revoked. Refused if this ' +
- 'would leave the team with zero members holding `members.manage`.',
+ 'would leave the team with zero members holding `members.manage`. Returns nothing ' +
+ 'on success.',
inputSchema: {
type: 'object',
properties: {
@@ -513,7 +528,9 @@ function buildFilesystemTools(name: string): Tool[] {
description:
`List the contents of a directory in the ac7 virtual filesystem. ` +
`Your home is \`${home}\`; passing "/" lists the set of homes you can see. ` +
- `Entries include per-item metadata (kind, size, mime type, owner).`,
+ `Entries include per-item metadata (kind, size, mime type, owner). ` +
+ `Objective namespaces are listable at \`/objectives//\` if you're a ` +
+ `member (originator, assignee, or watcher) of that objective.`,
inputSchema: {
type: 'object',
properties: {
@@ -555,13 +572,18 @@ function buildFilesystemTools(name: string): Tool[] {
`Upload a file. Pass EITHER \`text\` (UTF-8 string) or \`base64\` (for binary ` +
`content), never both. Parent directories are auto-created. By default errors on ` +
`collision; use collide="suffix" to auto-rename ("foo.txt" → "foo-1.txt") or ` +
- `"overwrite" to replace the existing file. You go by ${name}; your home is ${home}.`,
+ `"overwrite" to replace the existing file. You go by ${name}; your home is ${home}. ` +
+ `Returns the resulting FsEntry (path, name, size, mime, owner) — note the path ` +
+ `may differ from the requested one when collide="suffix" produced a rename.`,
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
- description: `Absolute path to write (must sit under ${home} unless you are a director).`,
+ description:
+ `Absolute path to write. Allowed under ${home} (your home), ` +
+ `under \`/objectives//\` for any objective you're a member of, ` +
+ `or anywhere if you're a director.`,
},
mimeType: {
type: 'string',
@@ -588,7 +610,7 @@ function buildFilesystemTools(name: string): Tool[] {
name: 'fs_mkdir',
description:
`Create a directory. Pass recursive=true to auto-create missing parents. ` +
- `You go by ${name}; your home is ${home}.`,
+ `You go by ${name}; your home is ${home}. Returns the directory's FsEntry.`,
inputSchema: {
type: 'object',
properties: {
@@ -603,7 +625,7 @@ function buildFilesystemTools(name: string): Tool[] {
description:
`Remove a file or directory. Directories require recursive=true if non-empty. ` +
`Deletion cascades blob refcounts — the underlying content is purged only when the ` +
- `last referencing entry across the filesystem goes away.`,
+ `last referencing entry across the filesystem goes away. Returns nothing on success.`,
inputSchema: {
type: 'object',
properties: {
@@ -620,7 +642,8 @@ function buildFilesystemTools(name: string): Tool[] {
name: 'fs_mv',
description:
`Rename / move a file. Directory moves are not currently supported. ` +
- `Both the source and destination must sit under a tree you own (or you must be a director).`,
+ `Both the source and destination must sit under a tree you own (or you must be a director). ` +
+ `Returns the FsEntry at the destination path.`,
inputSchema: {
type: 'object',
properties: {
@@ -633,9 +656,13 @@ function buildFilesystemTools(name: string): Tool[] {
{
name: 'fs_shared',
description:
- `List every file that has been shared with you via a message or objective ` +
- `attachment. Owner-private files from other slots never appear here — only ones ` +
- `a teammate explicitly attached to a thread you can see.`,
+ `List every file that has been shared with you via a message attachment — ` +
+ `entries another member explicitly attached to a thread you can see. Owner- ` +
+ `private files from other slots never appear here. Files that live in objective ` +
+ `namespaces you're a member of (\`/objectives//...\`) are NOT in this list ` +
+ `either; access there flows from membership, not grants — use \`fs_ls\` on ` +
+ `that namespace path to see them. Returns each file's FsEntry (path, size, mime, ` +
+ `owner).`,
inputSchema: { type: 'object', properties: {} },
},
];
@@ -672,7 +699,8 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] {
`contractual: it must state the tangible, verifiable result that defines "done", not ` +
`just a vague intent. Optionally include a \`body\` for additional context and ` +
`\`watchers\` (a list of names) to loop other teammates into the discussion thread ` +
- `from the start. Available assignees: ${teammateList}.`,
+ `from the start. Available assignees: ${teammateList}. Returns the new objective ` +
+ `with its generated id.`,
inputSchema: {
type: 'object',
properties: {
@@ -704,7 +732,7 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] {
type: 'array',
items: { type: 'string' },
description:
- 'Optional list of file paths to attach to the objective. Every thread member (originator, assignee, watchers, directors) receives automatic read access. Use `fs_write` to upload a file first.',
+ "Optional list of file paths to attach to the objective. Each is mirrored into the objective's namespace at `/objectives//` so the file lives with the objective rather than in your home; every thread member (originator, assignee, watchers, directors) gets read/write access via the namespace ACL. Use `fs_write` to upload a file first.",
},
},
required: ['title', 'outcome', 'assignee'],
@@ -722,7 +750,7 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] {
`shifted, the problem went away, the assignee is overwhelmed, etc. Cancellation is ` +
`terminal: a cancelled objective cannot be resumed (create a fresh one if you change ` +
`your mind). ${cancelScope} Include a \`reason\` so the assignee and any watchers ` +
- `understand why.`,
+ `understand why. Returns the now-cancelled objective.`,
inputSchema: {
type: 'object',
properties: {
@@ -748,7 +776,8 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] {
`lifecycle event and every discussion post on the objective — use this to loop in a ` +
`reviewer, a subject-matter expert, or anyone who should have awareness without ` +
`being the assignee. Directors are implicit members and never need to be added. ` +
- `${watchersScope} Pass \`add\` and/or \`remove\` as arrays of names.`,
+ `${watchersScope} Pass \`add\` and/or \`remove\` as arrays of names. Returns the ` +
+ `updated objective with its new watcher list.`,
inputSchema: {
type: 'object',
properties: {
@@ -777,7 +806,7 @@ function buildAuthorityTools(briefing: BriefingResponse): Tool[] {
`new assignee receive channel pushes — the previous one so they know the ` +
`objective left their plate, the new one so they know they now own it. Use this ` +
`when the initial assignee is overwhelmed, the wrong skill match, or unavailable. ` +
- `Director-only: managers cannot reassign.`,
+ `Returns the reassigned objective.`,
inputSchema: {
type: 'object',
properties: {
@@ -913,12 +942,10 @@ async function handleBroadcast(
if (!body) return errorResult('broadcast: `body` is required');
const levelResult = parseLevel(args.level);
if (levelResult.error) return errorResult(`broadcast: ${levelResult.error}`);
- const title = typeof args.title === 'string' ? args.title : null;
const attachments = await resolveAttachmentPaths(args.attachments, brokerClient);
if ('error' in attachments) return errorResult(`broadcast: ${attachments.error}`);
const result = await brokerClient.push({
body,
- title,
level: levelResult.level,
...(attachments.list.length > 0 ? { attachments: attachments.list } : {}),
});
@@ -939,13 +966,11 @@ async function handleSend(
if (!to || !body) return errorResult('send: `to` and `body` are required');
const levelResult = parseLevel(args.level);
if (levelResult.error) return errorResult(`send: ${levelResult.error}`);
- const title = typeof args.title === 'string' ? args.title : null;
const attachments = await resolveAttachmentPaths(args.attachments, brokerClient);
if ('error' in attachments) return errorResult(`send: ${attachments.error}`);
const result = await brokerClient.push({
to,
body,
- title,
level: levelResult.level,
...(attachments.list.length > 0 ? { attachments: attachments.list } : {}),
});
@@ -1091,7 +1116,6 @@ async function handleChannelsPost(
if (!body) return errorResult('channels_post: `body` is required');
const levelResult = parseLevel(args.level);
if (levelResult.error) return errorResult(`channels_post: ${levelResult.error}`);
- const title = typeof args.title === 'string' ? args.title : null;
const attachments = await resolveAttachmentPaths(args.attachments, brokerClient);
if ('error' in attachments) return errorResult(`channels_post: ${attachments.error}`);
@@ -1122,7 +1146,6 @@ async function handleChannelsPost(
const result = await brokerClient.push({
body,
- title,
level: levelResult.level,
data: { thread: `chan:${channelId}` },
...(attachments.list.length > 0 ? { attachments: attachments.list } : {}),
@@ -1238,14 +1261,12 @@ async function handleObjectivesDiscuss(
if (!id || !body) {
return errorResult('objectives_discuss: both `id` and `body` are required');
}
- const title = typeof args.title === 'string' ? args.title : undefined;
const attachmentsResult = await resolveAttachmentPaths(args.attachments, brokerClient);
if ('error' in attachmentsResult) {
return errorResult(`objectives_discuss: ${attachmentsResult.error}`);
}
const message = await brokerClient.discussObjective(id, {
body,
- ...(title !== undefined ? { title } : {}),
...(attachmentsResult.list.length > 0 ? { attachments: attachmentsResult.list } : {}),
});
const attachmentNote =
@@ -1405,7 +1426,7 @@ async function handleTeamGet(brokerClient: BrokerClient): Promise,
brokerClient: BrokerClient,
): Promise {
- const patch: { name?: string; directive?: string; brief?: string } = {};
+ const patch: { name?: string; directive?: string; context?: string } = {};
if (typeof args.name === 'string') patch.name = args.name;
if (typeof args.directive === 'string') patch.directive = args.directive;
- if (typeof args.brief === 'string') patch.brief = args.brief;
+ if (typeof args.context === 'string') patch.context = args.context;
if (Object.keys(patch).length === 0) {
- return errorResult('team_update: pass at least one of name, directive, brief');
+ return errorResult('team_update: pass at least one of name, directive, context');
}
const team = await brokerClient.updateTeam(patch);
return textResult(
diff --git a/packages/cli/test/commands/serve.test.ts b/packages/cli/test/commands/serve.test.ts
index 2d82c8a..cb947ce 100644
--- a/packages/cli/test/commands/serve.test.ts
+++ b/packages/cli/test/commands/serve.test.ts
@@ -228,7 +228,7 @@ function seedConfig(dir: string): string {
team: {
name: 'demo',
directive: 'ship',
- brief: '',
+ context: '',
permissionPresets: {},
},
members: [
diff --git a/packages/cli/test/runtime/bridge.test.ts b/packages/cli/test/runtime/bridge.test.ts
index 08255a4..a9d9000 100644
--- a/packages/cli/test/runtime/bridge.test.ts
+++ b/packages/cli/test/runtime/bridge.test.ts
@@ -34,7 +34,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { RunnerHandle } from '../../src/runtime/runner.js';
import { startRunner } from '../../src/runtime/runner.js';
import {
- FAKE_BROKER_MISSION,
FAKE_BROKER_NAME,
FAKE_BROKER_TEAM_NAME,
FAKE_BROKER_TOKEN,
@@ -219,7 +218,11 @@ describeIfBuilt('runner + bridge end-to-end', () => {
const broadcast = result.tools.find((t) => t.name === 'broadcast');
expect(broadcast?.description).toContain(FAKE_BROKER_NAME);
const roster = result.tools.find((t) => t.name === 'roster');
- expect(roster?.description).toContain(FAKE_BROKER_MISSION);
+ // Roster description references the team name and the agent's
+ // own identity, but not the directive — directive is already
+ // pinned in the briefing's MCP `instructions` field, so embedding
+ // it into tool descriptions would be duplicative.
+ expect(roster?.description).toContain(FAKE_BROKER_TEAM_NAME);
const listTool = result.tools.find((t) => t.name === 'objectives_list');
expect(listTool?.description).toContain('assigned to you');
@@ -237,7 +240,6 @@ describeIfBuilt('runner + bridge end-to-end', () => {
arguments: {
to: 'peer-1',
body: 'hello from runner/bridge test',
- title: 'greetings',
level: 'warning',
},
},
@@ -250,7 +252,6 @@ describeIfBuilt('runner + bridge end-to-end', () => {
const lastPush = broker.pushes[broker.pushes.length - 1];
expect(lastPush?.body).toBe('hello from runner/bridge test');
- expect(lastPush?.title).toBe('greetings');
expect(lastPush?.level).toBe('warning');
});
diff --git a/packages/cli/test/runtime/fake-broker.ts b/packages/cli/test/runtime/fake-broker.ts
index 97e8b41..729c255 100644
--- a/packages/cli/test/runtime/fake-broker.ts
+++ b/packages/cli/test/runtime/fake-broker.ts
@@ -136,7 +136,7 @@ export async function startFakeBroker(): Promise {
team: {
name: FAKE_BROKER_TEAM_NAME,
directive: FAKE_BROKER_MISSION,
- brief: '',
+ context: '',
permissionPresets: {},
},
teammates: [
diff --git a/packages/cli/test/runtime/tools.test.ts b/packages/cli/test/runtime/tools.test.ts
index 0d67b73..4fa15a9 100644
--- a/packages/cli/test/runtime/tools.test.ts
+++ b/packages/cli/test/runtime/tools.test.ts
@@ -32,7 +32,7 @@ const BRIEFING: BriefingResponse = {
team: {
name: 'demo',
directive: 'ship',
- brief: '',
+ context: '',
permissionPresets: {},
},
teammates: [
diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts
index bb24c6a..62d035a 100644
--- a/packages/cli/test/setup.test.ts
+++ b/packages/cli/test/setup.test.ts
@@ -65,7 +65,7 @@ describe('runSetupCommand', () => {
stores.team.setTeam({
name: 'demo-team',
directive: 'ship the payment service',
- brief: '',
+ context: '',
});
stores.members.addMember({
name: 'director-1',
diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts
index 034193e..af2a237 100644
--- a/packages/sdk/src/client.ts
+++ b/packages/sdk/src/client.ts
@@ -334,7 +334,7 @@ export class Client {
* Fetch the team-context briefing for the authenticated member.
*
* Returns the caller's name, role, permissions, team
- * (name/directive/brief/presets), list of teammates, open objectives
+ * (name/directive/context/presets), list of teammates, open objectives
* currently on the caller's plate, and the member's personal
* `instructions` string ready for `new Server({instructions})` in
* the MCP link.
@@ -532,7 +532,7 @@ export class Client {
}
/**
- * Read the current team config (name, directive, brief, presets).
+ * Read the current team config (name, directive, context, presets).
* Authenticated; available to every member.
*/
async getTeam(): Promise {
@@ -542,11 +542,11 @@ export class Client {
}
/**
- * Update one or more team-level fields (name, directive, brief).
+ * Update one or more team-level fields (name, directive, context).
* Requires `team.manage`. Permission presets are managed separately
* via `setPreset` / `deletePreset` so the API surface stays narrow.
*/
- async updateTeam(patch: Partial>): Promise {
+ async updateTeam(patch: Partial>): Promise {
const resp = await this.request(PATHS.team, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -1038,6 +1038,17 @@ export class Client {
return FsListResponseSchema.parse(await this.json(resp)).entries;
}
+ /**
+ * Admin-only flat enumeration of every file in every home, newest
+ * first. The server gates on `members.manage` and 403s otherwise;
+ * non-admins should keep using `fsList` per-home and `fsShared` for
+ * cross-home grants. Returned entries always have `kind === 'file'`.
+ */
+ async fsAll(): Promise {
+ const resp = await this.request(PATHS.fsAll, { method: 'GET' });
+ return FsListResponseSchema.parse(await this.json(resp)).entries;
+ }
+
/**
* Open a long-lived WebSocket subscription for the caller's member
* `name` and yield messages as they arrive. Aborts cleanly when
diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts
index 90f3c79..f769544 100644
--- a/packages/sdk/src/protocol.ts
+++ b/packages/sdk/src/protocol.ts
@@ -36,7 +36,7 @@ export const PATHS = {
// gate on the permission. The helpers below compose the `:name`
// subpaths.
members: '/members',
- // Team — name, directive, brief, permission presets. `GET /team` is
+ // Team — name, directive, context, permission presets. `GET /team` is
// dual-auth (every authenticated member sees the team they're on).
// `PATCH /team` requires `team.manage`. Permission-preset CRUD lives
// under `/team/presets` (same gate). Mutations apply immediately to
@@ -56,6 +56,7 @@ export const PATHS = {
fsRm: '/fs/rm',
fsMv: '/fs/mv',
fsShared: '/fs/shared',
+ fsAll: '/fs/all',
// Device-code enrollment (RFC 8628-shaped). `enroll` mints a
// device_code/user_code pair; `enrollPoll` is the device-side poll;
// `enrollPending` lists requests waiting for director approval;
diff --git a/packages/sdk/src/schemas.ts b/packages/sdk/src/schemas.ts
index ea1d941..a711a6f 100644
--- a/packages/sdk/src/schemas.ts
+++ b/packages/sdk/src/schemas.ts
@@ -47,7 +47,7 @@ export const RoleSchema = z.object({
export const TeamSchema = z.object({
name: z.string().min(1).max(128),
directive: z.string().min(1).max(512),
- brief: z.string().max(4096).default(''),
+ context: z.string().max(4096).default(''),
permissionPresets: PermissionPresetsSchema.default({}),
});
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index 4649b91..9b37770 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -55,11 +55,16 @@ export function hasPermission(permissions: readonly Permission[], required: Perm
* A team is the top-level unit the server controls. One deployment
* = one team. The team defines the directive and the context every
* member inherits, plus any reusable permission presets.
+ *
+ * `context` here is the team-level standing context (the longer
+ * background paragraph that complements `directive`). Distinct
+ * from agent conversation context — the latter is per-session and
+ * lives in the runner; the former is durable team configuration.
*/
export interface Team {
name: string;
directive: string;
- brief: string;
+ context: string;
/**
* Named permission bundles members can reference instead of listing
* every leaf permission. Always present (may be empty). Common
diff --git a/packages/web-shell/package.json b/packages/web-shell/package.json
index cdbbc02..b6cea6b 100644
--- a/packages/web-shell/package.json
+++ b/packages/web-shell/package.json
@@ -40,7 +40,10 @@
"dependencies": {
"@agentc7/sdk": "workspace:*",
"@preact/signals": "^2.9.0",
+ "dompurify": "^3.4.2",
+ "highlight.js": "^11.11.1",
"lucide-preact": "^1.14.0",
+ "marked": "^18.0.3",
"preact": "^10.29.1"
},
"devDependencies": {
diff --git a/packages/web-shell/src/TeamShell.tsx b/packages/web-shell/src/TeamShell.tsx
index e245063..757271c 100644
--- a/packages/web-shell/src/TeamShell.tsx
+++ b/packages/web-shell/src/TeamShell.tsx
@@ -45,6 +45,7 @@ import { ChannelHeader } from './components/ChannelHeader.js';
import { CommandPalette } from './components/CommandPalette.js';
import { Composer } from './components/Composer.js';
import { DisconnectedBanner } from './components/DisconnectedBanner.js';
+import { FilePreviewModal } from './components/FilePreviewModal.js';
import { FilesPanel } from './components/FilesPanel.js';
import { Header } from './components/Header.js';
import { InboxPanel } from './components/InboxPanel.js';
@@ -71,7 +72,7 @@ import {
import { type Identity, setIdentity } from './lib/identity.js';
import { closeInspector } from './lib/inspector.js';
import { startSubscribe, streamConnected } from './lib/live.js';
-import { appendMessages, dmOther, messagesByThread } from './lib/messages.js';
+import { appendMessages, dmOther, messagesByThread, objectiveThreadKey } from './lib/messages.js';
import { loadObjectives } from './lib/objectives.js';
import { closePalette, togglePalette } from './lib/palette.js';
import { initializePushState } from './lib/push.js';
@@ -247,14 +248,21 @@ export function TeamShell(props: TeamShellProps): JSX.Element {
// Auto-read the active thread: any time the view changes or a
// new message lands, bump lastRead for the active thread so its
// unread count stays at 0 while the viewer is watching it.
+ // Objective threads (`obj:`) don't have a top-level URL —
+ // they surface inside the objective detail view, so when that
+ // view is active the embedded thread is what the viewer is
+ // looking at and counts as read.
disposeAutoRead = effect(() => {
const current = view.value;
const map = messagesByThread.value;
- if (current.kind !== 'thread') return;
- const messages = map.get(current.key) ?? [];
+ let key: string | null = null;
+ if (current.kind === 'thread') key = current.key;
+ else if (current.kind === 'objective-detail') key = objectiveThreadKey(current.id);
+ if (key === null) return;
+ const messages = map.get(key) ?? [];
if (messages.length === 0) return;
const latest = messages[messages.length - 1];
- if (latest) markThreadRead(current.key, latest.ts);
+ if (latest) markThreadRead(key, latest.ts);
});
// Presence-freshness hook: every time the live stream transitions
@@ -328,6 +336,7 @@ export function TeamShell(props: TeamShellProps): JSX.Element {
)}
+
>
);
diff --git a/packages/web-shell/src/components/ActivityInspector.tsx b/packages/web-shell/src/components/ActivityInspector.tsx
index ed56e91..137eb9e 100644
--- a/packages/web-shell/src/components/ActivityInspector.tsx
+++ b/packages/web-shell/src/components/ActivityInspector.tsx
@@ -143,7 +143,7 @@ export function ActivityInspector({ agentName }: ActivityInspectorProps) {
class="activity-inspector flex flex-col flex-shrink-0"
aria-label={`Activity for ${agentName}`}
data-inspector-open={open ? 'true' : 'false'}
- style={`--activity-inspector-width:${width}px;height:100%;background:var(--paper);border-left:1px solid var(--rule);position:relative`}
+ style={`--activity-inspector-width:${width}px`}
>
{/* biome-ignore lint/a11y/useSemanticElements: is a thematic
break, not a draggable splitter — `role="separator"` with
diff --git a/packages/web-shell/src/components/AgentTimeline.tsx b/packages/web-shell/src/components/AgentTimeline.tsx
index b085ea7..063c748 100644
--- a/packages/web-shell/src/components/AgentTimeline.tsx
+++ b/packages/web-shell/src/components/AgentTimeline.tsx
@@ -51,7 +51,7 @@ const DEFAULT_FILTERS: KindFilter = {
objective_open: true,
objective_close: true,
llm_exchange: true,
- opaque_http: true,
+ opaque_http: false,
};
const kindFilters = signal({ ...DEFAULT_FILTERS });
diff --git a/packages/web-shell/src/components/Composer.tsx b/packages/web-shell/src/components/Composer.tsx
index 910fedd..6e51cf6 100644
--- a/packages/web-shell/src/components/Composer.tsx
+++ b/packages/web-shell/src/components/Composer.tsx
@@ -265,10 +265,16 @@ export function Composer({ viewer }: ComposerProps) {
};
const onKeyDown = (event: JSX.TargetedKeyboardEvent) => {
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault();
- void send();
+ if (event.key !== 'Enter' || event.shiftKey) return;
+ // On touch devices, the soft keyboard's "return" key should insert
+ // a newline (the native textarea behavior). Sending is the dedicated
+ // inline button on mobile, where Enter-to-send is unintuitive and
+ // an easy way to fire off half-typed thoughts.
+ if (typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches) {
+ return;
}
+ event.preventDefault();
+ void send();
};
const onInput = (event: JSX.TargetedInputEvent) => {
diff --git a/packages/web-shell/src/components/DisconnectedBanner.tsx b/packages/web-shell/src/components/DisconnectedBanner.tsx
index eaf0f40..dd61d2d 100644
--- a/packages/web-shell/src/components/DisconnectedBanner.tsx
+++ b/packages/web-shell/src/components/DisconnectedBanner.tsx
@@ -8,12 +8,21 @@
* State machine:
* - Healthy steady state → no toast
* - Connected, then dropped → "Disconnected" sticky toast
+ * (after a short grace window,
+ * so backgrounded-tab churn
+ * on focus return doesn't
+ * flash the warning)
* - Reconnected with backfill loss → "Reconnected" auto-dismiss toast
*
* The pre-first-open race (signal starts `false` until the WebSocket's
* first `open` event) is gated behind `streamEverConnected` so the
* shell doesn't shout "disconnected" during the normal connect window.
*
+ * The post-resume race (a previously-connected tab returning from the
+ * background briefly reads `connected: false` until the WebSocket
+ * re-opens) is gated by `DISCONNECT_GRACE_MS`. If reconnect lands
+ * within that window we never raise the toast at all.
+ *
* Dedup is handled by the `stream-status` tag — re-emitting replaces
* any previous stream-status toast in place. Healthy reconnect with
* no backfill loss explicitly clears the tag.
@@ -25,6 +34,13 @@ import { dismissToastsByTag, toast } from '../lib/toast.js';
const STATUS_TAG = 'stream-status';
+/**
+ * How long to wait after a disconnect before raising the toast. Tuned
+ * to absorb the typical tab-resume reconnect window (often <1 s) and
+ * brief network blips, without making real outages feel silent.
+ */
+const DISCONNECT_GRACE_MS = 3000;
+
export function DisconnectedBanner() {
// Read all three signals so the effect re-runs on any change.
const connected = streamConnected.value;
@@ -36,13 +52,19 @@ export function DisconnectedBanner() {
if (!connected && !everConnected) return;
if (!connected) {
- toast.warn({
- tag: STATUS_TAG,
- title: 'Disconnected',
- body: 'The live update stream is offline — trying to reconnect…',
- duration: null,
- });
- return;
+ // Defer the toast — if the stream reconnects within the grace
+ // window, the cleanup below clears the timer and the user sees
+ // nothing. This swallows the focus-return flash on browsers
+ // that pause WebSockets in backgrounded tabs.
+ const timer = setTimeout(() => {
+ toast.warn({
+ tag: STATUS_TAG,
+ title: 'Disconnected',
+ body: 'The live update stream is offline — trying to reconnect…',
+ duration: null,
+ });
+ }, DISCONNECT_GRACE_MS);
+ return () => clearTimeout(timer);
}
// Reconnected, but backfill failed — surface a transient warning,
diff --git a/packages/web-shell/src/components/FilePreviewModal.tsx b/packages/web-shell/src/components/FilePreviewModal.tsx
new file mode 100644
index 0000000..b4932f5
--- /dev/null
+++ b/packages/web-shell/src/components/FilePreviewModal.tsx
@@ -0,0 +1,177 @@
+/**
+ * FilePreviewModal — single global preview surface, mounted at the
+ * shell level. Reads `currentPreview` from `lib/file-preview.ts`;
+ * any component that wants to open a preview just calls
+ * `openPreview(file)` and the modal materializes here.
+ *
+ * Inline-handles the cheap native renderers (image / pdf / audio /
+ * video) since they're a single tag each and there's no benefit to
+ * code-splitting them. Text/Markdown/Code are pulled in via `lazy()`
+ * + `Suspense` so the bundle cost only lands when an operator
+ * actually opens that kind of file.
+ *
+ * The modal frame reuses `RouteModal` for backdrop / close / Escape
+ * handling so we get the same dismissal UX as everything else in
+ * the shell (Account, Team Settings, etc.).
+ */
+
+import { FS_PATHS } from '@agentc7/sdk/protocol';
+import { lazy, Suspense } from 'preact/compat';
+import { useMemo } from 'preact/hooks';
+import {
+ closePreview,
+ currentPreview,
+ type PreviewableFile,
+ type RendererSelection,
+ SIZE_CAPS,
+ selectRenderer,
+} from '../lib/file-preview.js';
+import { Download } from './icons/index.js';
+import { TextPreview } from './preview/TextPreview.js';
+import { RouteModal } from './RouteModal.js';
+
+// Lazy chunks — only fetched when the operator opens a preview of
+// the matching kind. The Markdown chunk pulls `marked` + `dompurify`
+// (~30KB gz); the Code chunk pulls `highlight.js/lib/common`
+// (~30KB gz). Each chunk loads exactly once per session.
+const MarkdownPreviewLazy = lazy(() =>
+ import('./preview/MarkdownPreview.js').then((m) => ({ default: m.MarkdownPreview })),
+);
+const CodePreviewLazy = lazy(() =>
+ import('./preview/CodePreview.js').then((m) => ({ default: m.CodePreview })),
+);
+
+function formatSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+}
+
+export function FilePreviewModal() {
+ const file = currentPreview.value;
+ if (file === null) return null;
+ return ;
+}
+
+function PreviewModalInner({ file }: { file: PreviewableFile }) {
+ // Recompute selection when the file changes. Cheap; memoized so
+ // re-renders from unrelated signals don't re-key the inner
+ // components.
+ const selection = useMemo(() => selectRenderer(file), [file.path, file.mimeType, file.size]);
+
+ return (
+
+
@@ -202,7 +222,7 @@ export function FilesPanel({ viewer, path }: FilesPanelProps) {
) : (
- Shared with you
+ {current.mode === 'all' ? 'All files (admin)' : 'Shared with you'}
)}
@@ -241,6 +261,23 @@ export function FilesPanel({ viewer, path }: FilesPanelProps) {
>
{current.mode === 'shared' ? 'Browse tree' : 'Shared with me'}
+ {/* All-files view is an admin-only convenience: every file
+ across every home in one flat list. The server enforces
+ the same gate; this hides the toggle for non-admins
+ rather than letting them click and 403. */}
+ {isAdmin && (
+
+ )}
{current.mode === 'shared'
? 'Nothing has been shared with you yet.'
- : 'This directory is empty.'}
+ : current.mode === 'all'
+ ? 'No files anywhere on the team yet.'
+ : 'This directory is empty.'}
+ );
+ }
+
+ // Line numbers: split on \n, render a parallel aligned with
+ // the highlighted code. Both panels share the same line height so
+ // the gutter stays in lockstep with wrapping disabled.
+ const lineCount = (state.raw ?? '').split('\n').length;
+ const lineNumbers = Array.from({ length: lineCount }, (_, i) => i + 1).join('\n');
+
+ return (
+
+
+
+ {language}
+
+
+
+
+
+ {lineNumbers}
+
+
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: highlight.js
+ produces escaped HTML — every token is wrapped in
+ with text nodes inside, no script execution path. The raw input is
+ text-only (fetched via fsRead), and ignoreIllegals stops malformed
+ input from desynchronizing the parser. */}
+
+
+
+
+ );
+}
diff --git a/packages/web-shell/src/components/preview/MarkdownPreview.tsx b/packages/web-shell/src/components/preview/MarkdownPreview.tsx
new file mode 100644
index 0000000..116dd39
--- /dev/null
+++ b/packages/web-shell/src/components/preview/MarkdownPreview.tsx
@@ -0,0 +1,80 @@
+/**
+ * MarkdownPreview — fetches a markdown file, parses with `marked`,
+ * sanitizes with `DOMPurify`, then renders the resulting HTML.
+ *
+ * Both libs are dynamic-imported so the SPA bundle doesn't pay for
+ * them until the operator opens their first markdown preview.
+ *
+ * Sanitization is non-negotiable: `marked` allows raw HTML in
+ * markdown by default, which would let a malicious uploader smuggle
+ * `