Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.0.8] - 2026-07-24

### Fixed

- Successfully scheduling a durable agent follow-up now ends the active turn
at the tool boundary. The runtime no longer makes a second model request or
briefly exposes a fabricated completion before removing it, eliminating a
timing race observed during the clean macOS release test.
- Gmail OAuth can now be completed from a remote browser by pasting its final
localhost callback URL into 1Helm. Automatic host-local callbacks still
work; the fallback validates the same one-time state and PKCE exchange.

### Changed

- The app's feedback surface and project privacy, support, and security
guidance now identify `build@1helm.com` as the company contact address.
- Notification audio is now a per-user preference: every member can mute all
pings globally, mute an individual channel, and choose that channel's sound
without changing anyone else's experience.

## [0.0.7] - 2026-07-24

### Fixed
Expand Down Expand Up @@ -201,7 +221,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
notarization, stapled tickets, Gatekeeper verification, persistent
Application Support, and isolated Apple container machines.

[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.7...HEAD
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.8...HEAD
[0.0.8]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.8
[0.0.7]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.7
[0.0.6]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.6
[0.0.5]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.5
Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Use GitHub's **Report a vulnerability** flow for
Include the affected version, platform, impact, minimum reproduction, and any
relevant logs after removing credentials and personal data.

For non-sensitive security questions or company contact, email
[`build@1helm.com`](mailto:build@1helm.com). Do not send unpatched vulnerability
details or secrets by ordinary email; use the private advisory flow above.

Do not open a public issue for an unpatched vulnerability. Do not include API
keys, OAuth tokens, Photon project secrets, private messages, workspace data,
Apple signing material, or other people's personal information in a report.
Expand Down
4 changes: 2 additions & 2 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function openAuthWindow(url) {
window.webContents.on("will-navigate", returnToApp);
window.webContents.on("will-redirect", returnToApp);
window.webContents.setWindowOpenHandler(({ url: nextUrl }) => {
if (/^https?:/i.test(nextUrl)) void shell.openExternal(nextUrl);
if (/^https?:/i.test(nextUrl) || /^mailto:build@1helm\.com$/i.test(nextUrl)) void shell.openExternal(nextUrl);
return { action: "deny" };
});
window.on("closed", () => { if (authWindow === window) authWindow = null; });
Expand All @@ -191,7 +191,7 @@ function createWindow(showWhenReady = true) {

window.webContents.setWindowOpenHandler(({ url }) => {
if (allowedAppUrl(url)) return { action: "allow" };
if (/^https?:/i.test(url)) void shell.openExternal(url);
if (/^https?:/i.test(url) || /^mailto:build@1helm\.com$/i.test(url)) void shell.openExternal(url);
return { action: "deny" };
});
window.webContents.on("will-navigate", (event, url) => {
Expand Down
19 changes: 16 additions & 3 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ Settings → Connections → Gmail owns the connection on the 1Helm host.
1. Once per Google Cloud project, create a Desktop app OAuth client, enable the
Gmail API, and choose the downloaded JSON in 1Helm.
2. Choose **Connect Gmail**. 1Helm opens a state-protected PKCE authorization and
receives the callback on loopback.
receives the callback on loopback. If the browser is on another device and
its `127.0.0.1` page cannot load, copy the complete callback URL from that
address bar and paste it into the Gmail connection card.
3. Return to 1Helm after Google confirms the account.

![Gmail and Photon are native host-brokered connections](assets/guide/connections.png)
Expand All @@ -237,8 +239,10 @@ accounts to that resident. Gmail supports account inventory, search, read, and
draft creation. Sending remains disabled. OAuth tokens live under 1Helm's
host-owned data and never enter chat, Activity evidence, or a resident computer.

The loopback callback belongs to the installed/local 1Helm host. A remotely
viewed demo cannot authorize Gmail on a different machine.
The pasted callback is never fetched. 1Helm validates its one-time state and
PKCE verifier, then exchanges the authorization code from the host where the
tokens belong. Automatic loopback completion still works when the browser is
running on that host.

## Photon / iMessage

Expand Down Expand Up @@ -283,6 +287,15 @@ Settings → Members and create an LLM-independent notice in the Captain's
model policy, or Files world. Channel membership gates HTTP, files, terminals,
messages, and WebSocket fan-out.

For privacy, support, or company questions, contact
[`build@1helm.com`](mailto:build@1helm.com). Sensitive vulnerability reports
belong in the repository's private GitHub security-advisory flow rather than
ordinary email.

Notification sounds are personal. Settings → Notifications provides a global
mute for the signed-in account. Each channel's Settings view can mute only that
channel or select its ping sound; these choices do not affect other members.

## Updates, removal, and recovery

Signed Mac releases are unique patch versions. Profile → Check for updates
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
{
"name": "1helm",
"productName": "1Helm",
"version": "0.0.7",
"version": "0.0.8",
"private": true,
"type": "module",
"description": "1Helm is the self-hosted home for durable AI employees: one resident, one private computer, compounding memory and skills, and Skipper for every boundary.",
"author": {
"name": "Joseph Yaksich",
"email": "build@1helm.com"
},
"main": "desktop/main.cjs",
"scripts": {
"assets:brand": "node scripts/generate-brand-assets.cjs",
Expand Down
2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@
</head>
<body class="h-screen w-screen overflow-hidden antialiased">
<div id="app" class="h-full w-full"></div>
<script type="module" src="/bundle.js?v=21954b038710"></script>
<script type="module" src="/bundle.js?v=6e8f6fac29f7"></script>
</body>
</html>
2 changes: 1 addition & 1 deletion scripts/run-test-suite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const suites = [
"test/channel-computers.mjs", "test/channel-computers-isolated-backends.mjs", "test/channel-computers-backend-migration.mjs",
"test/cloudflare-worker.mjs", "test/connectors.mjs", "test/chatgpt-image.mjs", "test/autonomy-platform.mjs",
"test/feedback.mjs", "test/feedback-browser.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs",
"test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/web-research.mjs", "test/workflows.mjs"],
"test/notifications.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/web-research.mjs", "test/workflows.mjs"],
];

let status = 0;
Expand Down
18 changes: 13 additions & 5 deletions src/client/app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadUsage, type RoutingModel } from "./api.ts";
import { h, clear, add, md, color, initials, timeLabel, dayLabel, sameDay, beep, icon, helmMark, type ChannelLink } from "./dom.ts";
import { h, clear, add, md, color, initials, timeLabel, dayLabel, sameDay, icon, helmMark, type ChannelLink } from "./dom.ts";
import { openSettings, finishOpenRouterOAuth, refreshOpenSkillsSettings } from "./settings.ts";
import { hydrateNotificationPreferences, playNotification } from "./notifications.ts";
import { pushRoutingActivity } from "./routing.ts";
import { openOnboarding } from "./onboarding.ts";
import { defaultTerminalComputer, openTerminals, refitChannelTerminals, getTerminalChrome } from "./term.ts";
Expand Down Expand Up @@ -103,6 +104,7 @@ function persistCurrentChannelView(): void {
async function loadUiState(): Promise<void> {
try {
const result = await api<{ state: Record<string, unknown> }>("/api/me/ui-state");
hydrateNotificationPreferences(result.state.notification_preferences);
const next: Record<number, ChannelUiView> = {};
for (const [key, value] of Object.entries(result.state || {})) {
const match = /^channel_view:(\d+)$/.exec(key);
Expand Down Expand Up @@ -385,7 +387,7 @@ function onEvent(e: any): void {
markChannelRead(msg.channel_id);
unreadBadgeCounted.add(msg.id);
}
if (e.type === "message" && !mine) beep(mentionsMe ? "mention" : "msg");
if (e.type === "message" && !mine) playNotification(msg.channel_id, mentionsMe ? "mention" : "msg");
// Stream ticks mutate one or two message rows. Rebuilding the whole thread
// panel here used to destroy the focused composer every 75 ms while an
// agent was working, which also reset selection and made scrolling jump.
Expand All @@ -396,11 +398,11 @@ function onEvent(e: any): void {
if (!unreadBadgeCounted.has(msg.id)) {
unreadBadgeCounted.add(msg.id);
bumpChannelUnread(msg.channel_id);
beep(mentionsMe ? "mention" : "msg");
playNotification(msg.channel_id, mentionsMe ? "mention" : "msg");
}
} else if (e.type === "message" && !mine && !messageIsSettled(msg)) {
// Working… started in another channel — amber dots come from agent_status; no badge yet.
beep(mentionsMe ? "mention" : "msg");
playNotification(msg.channel_id, mentionsMe ? "mention" : "msg");
}
} else if (e.type === "message_deleted") {
applyMessageDeleted(e);
Expand Down Expand Up @@ -868,7 +870,13 @@ function openFeedback(): void {
h("div", { class: "flex items-start justify-between gap-3" },
h("div", {},
h("h2", { class: "font-display text-xl text-fg" }, "Send feedback"),
h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Tell us what feels broken or what would make 1Helm better.")),
h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Tell us what feels broken or what would make 1Helm better."),
h("p", { class: "mt-1 text-xs leading-5 text-muted" }, "You can also email ", h("a", {
class: "text-accent hover:underline",
href: "mailto:build@1helm.com",
target: "_blank",
rel: "noopener",
}, "build@1helm.com"), ".")),
h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close feedback", onclick: close }, icon("x"))),
comment,
h("div", {},
Expand Down
21 changes: 21 additions & 0 deletions src/client/channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, type ActivityItem, type AgentTemplate, type Channel, type ChannelFile, type GlobalThread, type MemoryItem, type Message, type ThreadState, type RoutingModel } from "./api.ts";
import { h, clear, icon, md, timeLabel } from "./dom.ts";
import { S, avatar, appAlert, appConfirm, appPrompt } from "./app.ts";
import { NOTIFICATION_SOUNDS, channelNotificationPreference, previewNotification, setChannelNotificationPreference } from "./notifications.ts";

export type ChannelView = "chat" | "board" | "threads" | "files" | "terminal" | "memory" | "activity" | "settings";

Expand Down Expand Up @@ -614,6 +615,21 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel,
const provider = h("select", { class: "field" }, h("option", { value: "" }, "Loading providers…")) as HTMLSelectElement;
const model = h("select", { class: "field" }, h("option", { value: channel.agent?.model || "" }, channel.agent?.model || "Choose a model")) as HTMLSelectElement;
const status = h("p", { class: "min-h-5 text-sm text-muted" });
const notificationPreference = channelNotificationPreference(channel.id);
const channelMuted = h("input", { type: "checkbox", checked: notificationPreference.muted, class: "accent-accent" }) as HTMLInputElement;
const channelSound = h("select", { class: "field" }, ...NOTIFICATION_SOUNDS.map((item) => h("option", { value: item.value, selected: item.value === notificationPreference.sound }, item.label))) as HTMLSelectElement;
const notificationStatus = h("p", { class: "min-h-5 text-sm text-muted" });
const saveChannelNotifications = async (): Promise<void> => {
channelMuted.disabled = true; channelSound.disabled = true; notificationStatus.textContent = "Saving…";
try {
await setChannelNotificationPreference(channel.id, { muted: channelMuted.checked, sound: channelSound.value as typeof notificationPreference.sound });
notificationStatus.textContent = channelMuted.checked ? `#${channel.name} is muted for your account.` : `#${channel.name} will use ${channelSound.selectedOptions[0]?.textContent || "this sound"}.`;
} catch (error) { notificationStatus.textContent = (error as Error).message; }
finally { channelMuted.disabled = false; channelSound.disabled = channelMuted.checked; }
};
channelMuted.onchange = () => { channelSound.disabled = channelMuted.checked; void saveChannelNotifications(); };
channelSound.onchange = () => { previewNotification(channelSound.value as typeof notificationPreference.sound); void saveChannelNotifications(); };
channelSound.disabled = channelMuted.checked;
let loadSequence = 0;
let modelLoading = false;
let changeModelButton: HTMLButtonElement | null = null;
Expand Down Expand Up @@ -744,6 +760,11 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel,
? null
: h("button", { class: "btn-primary text-sm", onclick: () => { void saveName(); } }, "Rename"))),
h("div", { class: "card space-y-3 p-4" }, h("h3", { class: "font-semibold text-fg" }, "Purpose"), purpose, h("div", { class: "flex justify-end" }, h("button", { class: "btn-primary text-sm", onclick: () => { void savePurpose(); } }, "Save purpose"))),
h("div", { class: "card space-y-3 p-4", dataset: { channelNotifications: "" } },
h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Notification sound"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Private to your account. Global mute in Settings → Notifications always takes priority.")),
h("label", { class: "flex items-center gap-3 rounded-lg border border-line bg-panel p-3 text-sm font-semibold text-fg" }, channelMuted, `Mute #${channel.name}`),
h("label", { class: "block space-y-1 text-xs font-semibold text-fg" }, "Ping sound", channelSound),
notificationStatus),
h("div", { class: "card space-y-3 p-4" },
h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Agent avatar"), h("p", { class: "mt-1 text-sm text-muted" }, "Pick a flat color or upload a custom image for this resident agent.")),
agentAvatar, h("div", { class: "mt-2" }, h("span", { class: "mb-1 block text-xs font-semibold text-muted" }, "Default colors"), avatarColorRow)),
Expand Down
23 changes: 15 additions & 8 deletions src/client/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,21 +200,28 @@ export function icon(name: keyof typeof ICONS | string, size = 16): SVGElement {
}

let audioCtx: AudioContext | null = null;
export type NotificationSound = "helm" | "bell" | "chime" | "pulse";
/** Synthesized notification chirp — no asset files needed. */
export function beep(kind: "msg" | "mention" = "msg"): void {
export function beep(kind: "msg" | "mention" = "msg", sound: NotificationSound = "helm"): void {
try {
audioCtx = audioCtx || new (window.AudioContext || (window as any).webkitAudioContext)();
const ctx = audioCtx;
const notes = kind === "mention" ? [880, 1320] : [660, 990];
notes.forEach((freq, i) => {
const voices: Record<NotificationSound, { notes: number[]; wave: OscillatorType; gap: number; duration: number; gain: number }> = {
helm: { notes: kind === "mention" ? [880, 1320] : [660, 990], wave: "sine", gap: 0.09, duration: 0.16, gain: 0.12 },
bell: { notes: kind === "mention" ? [1046.5, 1568] : [1046.5], wave: "triangle", gap: 0.13, duration: 0.32, gain: 0.1 },
chime: { notes: kind === "mention" ? [659.25, 783.99, 1046.5] : [523.25, 659.25, 783.99], wave: "sine", gap: 0.075, duration: 0.22, gain: 0.09 },
pulse: { notes: kind === "mention" ? [587.33, 783.99] : [440, 587.33], wave: "square", gap: 0.065, duration: 0.1, gain: 0.045 },
};
const voice = voices[sound] || voices.helm;
voice.notes.forEach((freq, i) => {
const osc = ctx.createOscillator(); const gain = ctx.createGain();
osc.type = "sine"; osc.frequency.value = freq;
const t0 = ctx.currentTime + i * 0.09;
osc.type = voice.wave; osc.frequency.value = freq;
const t0 = ctx.currentTime + i * voice.gap;
gain.gain.setValueAtTime(0, t0);
gain.gain.linearRampToValueAtTime(0.12, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.16);
gain.gain.linearRampToValueAtTime(voice.gain, t0 + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + voice.duration);
osc.connect(gain); gain.connect(ctx.destination);
osc.start(t0); osc.stop(t0 + 0.18);
osc.start(t0); osc.stop(t0 + voice.duration + 0.02);
});
} catch { /* audio not available */ }
}
Loading
Loading