Skip to content

Commit d5adb04

Browse files
committed
Merge linked working-folder setup into beta
Signed-off-by: Tiberiu Socaci <tsocaci@makeitfuture.com>
2 parents 895e76c + ce3ffbe commit d5adb04

28 files changed

Lines changed: 433 additions & 140 deletions

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog — ChannelGate
22

3+
- Automatically link conversations sharing a working folder to one live setup, show the source in settings, and keep harness/model choices independent.
4+
5+
36
- Show configured effort and a compact live activity phase beside the running model in Slack’s native Stop label.
47

58
ChannelGate was formerly *Claude Gateway for Slack*; entries below the rename keep their original

‎FEATURES.md‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# ChannelGate — Features
22

3+
## Linked working-folder setup
4+
5+
- Conversations assigned to an existing folder follow its established owner live: skills/template and scoped catalog skills, memory, instructions, access, connectors/channel secrets, VPN service metadata, mode/Auto/Lean/network and Drive setup. Shared edits direct users to the source; harness/model/effort/authentication and personal grants remain independent.
6+
- Admin and Slack settings identify the source; the folder picker explains linking. Durable files use owner identity/platform; cross-platform turns receive actual surface capabilities separately. Drive sync has one owner. Unlinking retains the effective setup; owner departure promotes a remaining conversation with its saved setup.
7+
8+
39
Slack’s native running/Stop label shows the resolved model and explicitly configured effort, plus compact live phases (Thinking, Using tools, Writing, Waiting, Queued, Coordinating agents). Engine fallback replaces both model and effort; unset effort is omitted rather than guessed.
410

511
## User-linked dashboard passkeys
@@ -1099,11 +1105,7 @@ revocation removes future runtime grants. Source path/symlink/size validation re
10991105
revisions on failed sync. Details and compatibility limits: `docs/SKILLS.md`.
11001106

11011107

1102-
- **Slack reports shared-folder conflicts:** an authorized mention or typed command that cannot
1103-
start because channels share a folder with conflicting skills or memory settings gets a reply
1104-
in its triggering thread. The reply asks an admin to assign separate folders or align settings,
1105-
then retry. It exposes no other channel names or filesystem paths. The conflict still blocks
1106-
workspace writes and engine startup. → TEST-PLAN: Slack shared-folder conflict replies.
1108+
- **Shared-folder conversations link automatically:** incompatible initial skills/memory adopt the established source setup. Settings identify the source, and divergent metadata edits explain where to edit. Engine/model choices stay local. → TEST-PLAN: Linked working-folder setup acceptance.
11071109

11081110
- **Read/search account routing:** unrestricted information requests may search either or both
11091111
available Composio identities without an account-selection interruption. Explicit account and
@@ -5380,12 +5382,8 @@ are retired, bullet by bullet; everything else stands.
53805382
mirror remains current. Bundled source skills live in `src/gateway/skills/bundled/`.
53815383
- Skills admin saves refresh existing workspaces immediately. Boot and a five-second daemon
53825384
reconciliation pass refresh changed catalog/template/grant state, including MCP changes. Failed
5383-
writes are reported and retried; conflicting selections for a shared folder are reported.
5384-
- The Admin UI makes shared-folder assignments visible before they break a run. Every channel or
5385-
DM whose effective working folder is assigned to another conversation gets a red warning row in
5386-
Conversations. The working-folder browser shows a red, named warning whenever the directory being
5387-
viewed is already assigned elsewhere, before **Use this folder** can create another shared
5388-
assignment. Both views use the runtime's resolved path logic rather than raw string comparison.
5385+
writes are reported and retried; shared folders follow one linked owner setup.
5386+
- The Admin UI shows shared-folder conversations with a linked marker. The folder picker names existing assignments and explains inheritance; the editor opens the source conversation and locks inherited settings. Both views use the runtime's resolved path logic rather than raw strings.
53895387
- Runtime settings provide **Reset to default** beside **Browse**. It clears only the custom
53905388
working folder through the normal Save/Discard flow; the default remains
53915389
`~/ChannelGate/<platform>/<slug>/`. Existing files stay in their original location.

‎TEST-PLAN.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# ChannelGate — Test Plan
22

3+
## Linked working-folder setup acceptance
4+
5+
Automated: `test/skills-workspace-sync.test.js`, `test/code-channels.test.js`, `test/workspace-conflict-reply.test.js`, `test/workspace-conflict-admin.test.js`, `test/workdir-boundary.test.js`. Verify automatic inheritance instead of skills/memory refusal, live source edits, owner-scoped skills, model independence, shared-write rejection, operator service secret filtering, established-owner selection, owner departure and explicit unlinking. `test/drivesync.test.js` covers sync selection; linked rows must never create a second bisync job.
6+
7+
Live fixture (pending, run once with Claude and once with Codex): disposable private channels `cg-qa-linked-source` and `cg-qa-linked-follower`, same empty project directory, harmless test skill and memory sentinel. Set source Worker/network on; give follower different initial skills/memory. Select source folder from follower in Admin UI. Confirm source label/button, inherited controls, no conflict error, equal durable skill tree and source-scoped skill. Prompt follower: “Report your configured engine/model and the available shared test skill; add a test memory fact.” Change source skill/memory, retry, then change follower harness/model and confirm only runtime changes. Reject follower shared-setting edits with source guidance. Open an approved code channel from source and confirm it starts. Unlink follower and verify independent settings resume. Record message links, settings screenshots and engine evidence; no live PASS is claimed by automated checks. Restore/delete disposable fixtures. Cross-platform fixture: share a source with a Teams/Google Chat follower and verify actual-surface preamble while owner guide bytes remain stable. Drive fixture: valid disposable Drive folder, one scheduled job and one state directory, follower manual sync resolves to owner.
8+
9+
310
Running label acceptance (Claude and Codex): in a Slack agent thread with chat:write.customize, select an explicit effort using /model, then ask “Read README.md and explain the startup flow.” Confirm Stop shows the selected model (effort), transitions through live tool/writing phases, and becomes active without an activity suffix after completion or Stop. Repeat with each engine and a different effort; verify fallback replaces model/effort. Pass only with observed Slack evidence. Live execution pending; automated coverage: test/slack-progress.test.js native Stop effort/phase test and test/slack-stop-progress.test.js late-event cancellation fixtures.
411

512
## User-linked dashboard access (2026-10-25)
@@ -3882,7 +3889,9 @@ observed tool/process/filesystem evidence, and verdict. A missing, skipped, or b
38823889
is not a pass. Maintain the private QA registry alongside these reusable public instructions.
38833890

38843891

3885-
## Shared-folder conflict detection and warnings
3892+
## Shared-folder conflict detection and warnings (historical)
3893+
3894+
Superseded by **Linked working-folder setup acceptance** at the top of this plan. The old rejection/red-warning expectations below describe historical behavior, not current acceptance gates.
38863895

38873896
Automated regression: `node --test test/workspace-conflict-reply.test.js test/skills-workspace-sync.test.js
38883897
test/workspace-conflict-admin.test.js`.

‎documentation/src/content/docs/features/shared-folder-conflicts.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@ A Git branch alone does not isolate working files. Use a distinct worktree for e
1616

1717
The gateway's per-thread queue orders requests within that thread. It does not lock every project file against work in another thread or an authorized developer session.
1818

19-
## Align conversations sharing one folder
19+
## Linked folder setup
2020

21-
A working folder also contains gateway-managed instructions, shared skill materialization, and portable memory files. Two conversations pointed at that folder cannot safely impose incompatible managed selections.
21+
Selecting a folder already assigned to another conversation links to that conversation's shared setup automatically. The established folder owner remains the source even when an older conversation joins later. Existing duplicate assignments resolve to the first stored conversation.
2222

23-
ChannelGate checks compatible shared skill grants and memory settings before workspace synchronization. A conflict refuses provisioning with guidance to separate the folders or align the selections. It does not silently replace the other conversation's managed setup. The conflict notice does not disclose another conversation's identity or private folder path.
23+
Skills (including owner-scoped catalog skills), templates, memory settings and files, instructions, access policy, connectors, channel secrets, mode, Auto, Lean, network and Drive configuration follow the source live. Edit shared settings in the source conversation; attempts to create conflicting selections in a follower explain where to edit. Personal accounts and grants stay personal. Harness, model, effort, engine authentication and sessions remain independent.
2424

25-
An administrator should identify every conversation assigned to the folder, compare direct grants, template-derived grants, organization grants, and memory settings, then either align them or assign separate paths. Separate paths are usually easier to reason about for unrelated projects.
25+
The conversation list shows a linked-folder marker. The folder picker explains inheritance before selection; the conversation editor names the source and provides an Open source conversation button. Slack Settings also identifies the source. One owner runs Drive synchronization; follower sync requests use that same job and state.
26+
27+
Changing the working folder unlinks the conversation, retaining the last effective setup as its starting point. When the owner leaves, the first remaining conversation becomes the source using its saved setup. The gateway still refuses its private runtime/configuration trees and parent folders that would expose multiple workspaces. Folder assignment remains an administrator operation.
28+
29+
A shared folder keeps the owner's operating guide stable; a follower on another chat platform receives its actual surface capabilities in each turn's prompt.
2630

2731
## Confirm the effective path
2832

‎public/app.js‎

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,7 +1459,7 @@ function workDirConflictSummary(conflict) {
14591459
const names = others.map((item) => item.isDM || item.type === "im"
14601460
? (item.name || item.slug)
14611461
: hashName(item.name || item.slug));
1462-
return `Working folder is also assigned to ${names.join(", ")}`;
1462+
return `Working folder setup is linked with ${names.join(", ")}`;
14631463
}
14641464

14651465
function convRow(key, color, name, sub, cost, conflict = null) {
@@ -1471,10 +1471,10 @@ function convRow(key, color, name, sub, cost, conflict = null) {
14711471
// Compact whole-dollar 30-day cost (skip sub-$1 rows so the list stays quiet).
14721472
const dollars = cost == null ? null : Math.round(cost);
14731473
const costHtml = dollars && dollars >= 1 ? `<span class="conv-cost">$${escapeHtml(dollars.toLocaleString())}</span>` : "";
1474-
const conflictHtml = conflictText ? '<span class="conv-conflict-mark" aria-label="Working folder conflict">!</span>' : "";
1474+
const conflictHtml = conflictText ? '<span class="conv-conflict-mark" aria-label="Linked working folder">↔</span>' : "";
14751475
el.innerHTML =
14761476
`<span class="capdot" style="background:${color}"></span>` +
1477-
`<span class="conv-nm"><b>${escapeHtml(name)}</b>${sub ? `<small>${escapeHtml(sub)}</small>` : ""}${conflictText ? '<small class="conv-conflict-text">Shared working folder</small>' : ""}</span>` +
1477+
`<span class="conv-nm"><b>${escapeHtml(name)}</b>${sub ? `<small>${escapeHtml(sub)}</small>` : ""}${conflictText ? '<small class="conv-conflict-text">Linked folder setup</small>' : ""}</span>` +
14781478
conflictHtml +
14791479
costHtml;
14801480
el.addEventListener("click", (e) => {
@@ -2098,15 +2098,16 @@ function renderChannelDetail(ch) {
20982098
}
20992099
const inherited = () => {
21002100
const template = SKILL_TEMPLATES.find((t) => t.slug === templateSelect.value);
2101-
const section = catalog.skills.filter((s) => s.channelScope === ch.channelId && !s.deleted && s.visibility !== "personal").map((s) => s.slug);
2101+
const section = catalog.skills.filter((s) => s.channelScope === (meta.workspaceLink?.channelId || ch.channelId) && !s.deleted && s.visibility !== "personal").map((s) => s.slug);
21022102
return [
21032103
{ id: "organization", label: "Organization skills", slugs: overview.orgSkills || [], note: "Managed organization-wide." },
21042104
{ id: "template", label: "Template skills", slugs: template?.resolved || [], note: template ? `Included by ${template.name}. Edit the template in Skills → Templates.` : "Choose a template above to include its skills." },
21052105
...(section.length ? [{ id: "section", label: "Conversation skills", slugs: section, note: "Included from this conversation’s repository section." }] : []),
21062106
];
21072107
};
21082108
skillsPicker = mountSkillAssignmentPicker(skillsBox, { skills: catalog.skills, sources: catalog.sources, selected: meta.skills || [], inherited: inherited(), onChange: markDirty });
2109-
templateSelect.disabled = false;
2109+
templateSelect.disabled = !!meta.workspaceLink;
2110+
if (meta.workspaceLink) for (const input of skillsBox.querySelectorAll("input,button,select")) input.disabled = true;
21102111
templateSelect.addEventListener("change", () => skillsPicker.update({ inherited: inherited() }));
21112112
}).catch((error) => {
21122113
if (!skillsBox.isConnected) return;
@@ -2177,6 +2178,32 @@ function renderChannelDetail(ch) {
21772178
card.querySelector(".ch-nodefaulttokens").checked = !!meta.noDefaultTokens;
21782179
const workdirInput = card.querySelector(".ch-workdir");
21792180
workdirInput.value = meta.workDir || "";
2181+
if (meta.workspaceLink) {
2182+
const notice = document.createElement("p");
2183+
notice.className = "hint";
2184+
notice.textContent = `Linked folder setup: ${meta.workspaceLink.name}. Skills, templates, memory, instructions, mode, network and Drive sync follow this source. Edit shared settings there; choose a separate folder to unlink. Harness and model remain independent.`;
2185+
const source = document.createElement("button");
2186+
source.type = "button";
2187+
source.textContent = "Open source conversation";
2188+
source.addEventListener("click", () => {
2189+
const id = meta.workspaceLink.channelId;
2190+
selectConv((DMS.some((d) => d.channelId === id) ? "dm:" : "ch:") + id);
2191+
});
2192+
for (const cap of capCards) { cap.style.pointerEvents = "none"; cap.setAttribute("aria-disabled", "true"); }
2193+
for (const pane of card.querySelectorAll('.subpane[data-pane="access"], .subpane[data-pane="connections"], .subpane[data-pane="mcps"], .subpane[data-pane="environment"], .subpane[data-pane="skills"]')) {
2194+
const locked = document.createElement("fieldset");
2195+
locked.disabled = true;
2196+
locked.style.cssText = "border:0;padding:0;margin:0;min-width:0";
2197+
while (pane.firstChild) locked.appendChild(pane.firstChild);
2198+
pane.appendChild(locked);
2199+
}
2200+
notice.appendChild(source);
2201+
card.querySelector(".dhead").after(notice);
2202+
for (const selector of [".ch-memory", ".ch-syncdrive", ".ch-synctest", ".ch-profile", ".ch-network", ".ch-admin", ".ch-bash", ".ch-auto", ".ch-clean"]) {
2203+
const input = card.querySelector(selector);
2204+
if (input) { input.disabled = true; input.title = "Edit this shared setting in the source conversation"; }
2205+
}
2206+
}
21802207
card.querySelector(".ch-browse").addEventListener("click", () => openFolderPicker(workdirInput));
21812208
card.querySelector(".ch-workdir-reset").addEventListener("click", () => {
21822209
if (!workdirInput.value) return;
@@ -2429,7 +2456,7 @@ function renderChannelDetail(ch) {
24292456
}
24302457
const result = await api(`/api/channels/${encodeURIComponent(ch.channelId)}/meta`, {
24312458
method: "PUT",
2432-
body: JSON.stringify({
2459+
body: JSON.stringify(Object.fromEntries(Object.entries({
24332460
...(harnessPolicyDirty ? { approvedEngines: approvals } : {}),
24342461
...channelGuestSavePatch(usersBox.dataset.ready === "1", explicitCheckedValues(usersBox), usersBox.dataset.changed === "1"),
24352462
allowedMcps: selectedMcpEntries(mcpsBox, "claude")
@@ -2467,7 +2494,7 @@ function renderChannelDetail(ch) {
24672494
makeToolboxUrl: makeToolboxUrlInput.value,
24682495
...(makeToolboxKey ? { makeToolboxKey } : {}),
24692496
...(clearMakeToolbox ? { clearMakeToolbox: true } : {}),
2470-
}),
2497+
}).filter(([key]) => !meta.workspaceLink?.fields?.includes(key)))),
24712498
});
24722499
// The server response is the validated, committed record. Reconcile the cached channel from
24732500
// that whole record so a later SPA re-render cannot resurrect stale MCP/skill selections.
@@ -2499,6 +2526,10 @@ function renderChannelDetail(ch) {
24992526
// conversation collections from the authoritative server rather than repainting stale flags.
25002527
if (!(await refreshConversationRows())) renderConvList();
25012528
detailDirty = false;
2529+
if (meta.workspaceLink?.slug !== ch.meta.workspaceLink?.slug) {
2530+
renderChannelDetail(ch);
2531+
return;
2532+
}
25022533
savebarMsg.textContent = "Saved";
25032534
savebarMsg.classList.add("clean");
25042535
setTimeout(() => { if (!detailDirty) savebar.hidden = true; }, 1500);
@@ -2882,7 +2913,14 @@ function renderDmDetail(id) {
28822913
<select class="dm-template"><option value="user">User template</option><option value="admin">Admin template</option><option value="custom">Custom</option></select>`;
28832914
card.appendChild(tplField);
28842915
const tplSel = tplField.querySelector(".dm-template");
2885-
tplSel.value = dm.template;
2916+
tplSel.value = dm.meta?.workspaceLink ? "custom" : dm.template;
2917+
if (dm.meta?.workspaceLink) {
2918+
tplSel.disabled = true;
2919+
const linked = document.createElement("p");
2920+
linked.className = "hint";
2921+
linked.textContent = `Shared setup linked to ${dm.meta.workspaceLink.name}. Edit shared settings in that source conversation; harness, model and effort remain independent.`;
2922+
head.after(linked);
2923+
}
28862924

28872925
const customBox = document.createElement("div");
28882926
customBox.className = "dm-custom";
@@ -2891,7 +2929,13 @@ function renderDmDetail(id) {
28912929
const renderCustom = () => {
28922930
customBox.innerHTML = "";
28932931
ed = null;
2894-
if (tplSel.value === "custom") { ed = buildConfigEditor(dm.meta || {}); customBox.appendChild(ed.el); }
2932+
if (tplSel.value === "custom") {
2933+
ed = buildConfigEditor(dm.meta || {});
2934+
customBox.appendChild(ed.el);
2935+
if (dm.meta?.workspaceLink) for (const input of ed.el.querySelectorAll("input,select,button")) {
2936+
if (!input.matches(".cfg-engine,.cfg-model,.cfg-effort")) input.disabled = true;
2937+
}
2938+
}
28952939
};
28962940
renderCustom();
28972941
paintCap(tplSel.value);
@@ -2903,7 +2947,8 @@ function renderDmDetail(id) {
29032947
card, bar,
29042948
async () => {
29052949
const tpl = tplSel.value;
2906-
const body = { template: tpl, ...(tpl === "custom" && ed ? ed.getValues() : {}) };
2950+
const values = tpl === "custom" && ed ? ed.getValues() : {};
2951+
const body = { template: tpl, ...Object.fromEntries(Object.entries(values).filter(([key]) => !dm.meta?.workspaceLink?.fields?.includes(key))) };
29072952
await api(`/api/dms/${encodeURIComponent(dm.channelId)}`, { method: "PUT", body: JSON.stringify(body) });
29082953
dm.template = tpl;
29092954
renderConvList();
@@ -4622,7 +4667,7 @@ async function fsBrowse(p) {
46224667
const names = otherAssignments.map((item) => item.isDM || item.type === "im"
46234668
? (item.name || item.slug)
46244669
: hashName(item.name || item.slug));
4625-
conflict.textContent = `Already assigned to ${names.join(", ")}. Selecting it here will share one working folder between conversations.`;
4670+
conflict.textContent = `Already assigned to ${names.join(", ")}. Selecting it links this conversation to the existing folder setup: skills, memory, instructions, mode, network and Drive sync. Harness and model stay independent.`;
46264671
conflict.hidden = false;
46274672
} else {
46284673
conflict.textContent = "";

0 commit comments

Comments
 (0)