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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.5.0] - 2026-09-06

### Added

- Threads now expose their complete copyable number, can hand off current state and evidence into a fresh thread, and can retry an agent reply using the model selected at click time.
- Silent scheduled checks appear chronologically as compact, expandable Follow-up activity groups with their disposition, evidence, and work log.
- Web/PWA and desktop clients can deliver system notifications that navigate back to the relevant channel or thread; existing iOS/APNs delivery remains supported. Android notification scaffolding is fail-closed and does not require Firebase provisioning.
- Provider model catalogs can refresh automatically every 24 hours on an opt-in basis. OpenRouter can instead track only its current free catalog.
- Messages render trusted-delimiter inline and display TeX as safe KaTeX MathML, with inert fallback for malformed or untrusted expressions and local scrolling for wide equations.

### Changed

- Operational history is canonical across turns and restarts: tool calls and results stay paired, interrupted actions settle durably, provider IDs remain unique, and malformed legacy history is repaired only in model projection.
- Automatic wakes must complete with verified evidence, persist a successor follow-up, or stop at a real human boundary. Each thread permits only one pending follow-up chain.
- Thread usage is calculated natively from the exact outbound context and received model output. Context and cached values describe the latest call, while output and successful call count remain cumulative.
- Provider refresh dialogs are scrollable and searchable by model name or ID. Manual Override is opt-in and, when enabled, makes the checked set the exact active model selection, including a valid zero-model selection.
- Embedded ReRouted advances to 0.5.14 so Claude OAuth receives complete forwarded system context instead of a 400-character truncation.

### Fixed

- Agent turns no longer publish raw command output as a final answer or execute incomplete tool calls. Every provider request receives at least a 100,000-token output budget, and output-limit truncation fails closed.
- Web, PWA, and native mobile clients recover authoritatively after suspension or foregrounding, replacing stale WebSockets and refreshing the exact open thread without losing route, scroll, focus, expanded details, or composer text.
- Provider refresh and save actions remain reachable on short screens and large catalogs, including when search and Free-only filters are active.

## [1.4.2] - 2026-09-02

### Fixed
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,12 @@ in the iOS Keychain or encrypted with a key held by Android Keystore.
session token. Native bridge access is restricted to that exact scheme,
host, and port. Use **Disconnect** in the profile menu to erase both from the
device.
- iPhone notifications are opt-in under **Settings → Notifications**. Device
registration belongs to the signed-in account, per-channel mute still wins,
and tapping an update opens its channel or thread. iOS camera and photo
access is requested only after an explicit attachment action.
- System notifications are opt-in under **Settings → Notifications**. The web
GUI and installed PWA use durable Web Push, Android and iPhone use native
push, and the macOS/Windows app uses native desktop notifications while it is
running. Registrations belong to the signed-in account, per-channel mute
still wins, and tapping an update opens its channel or thread. iOS camera and
photo access is requested only after an explicit attachment action.

## Ready on day one. Specialized by day one hundred.

Expand Down
14 changes: 7 additions & 7 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ dependencies {

apply from: 'capacitor.build.gradle'

try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
def servicesJSON = file('google-services.json')
if (servicesJSON.isFile() && servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
} else if (signingPropertiesPath) {
throw new GradleException('Release builds require android/app/google-services.json so Android notifications cannot silently ship disabled.')
} else {
logger.info("google-services.json not found; debug Push Notifications are unavailable")
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.gitcommit90.onehelm.mobile;

import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.CapConfig;
Expand All @@ -24,5 +27,10 @@ protected void onCreate(Bundle savedInstanceState) {
.create();
}
super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel("1helm_activity", "1Helm activity", NotificationManager.IMPORTANCE_HIGH);
channel.setDescription("Channel and resident-agent updates");
getSystemService(NotificationManager.class).createNotificationChannel(channel);
}
}
}
15 changes: 15 additions & 0 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Central push relay configuration

The workspace provisioner Worker also delivers encrypted native-device push
tokens. Its runtime needs:

- APNs: `APNS_TEAM_ID`, `APNS_KEY_ID`, and secret `APNS_PRIVATE_KEY`.
- Firebase Cloud Messaging HTTP v1: `FCM_PROJECT_ID`, `FCM_CLIENT_EMAIL`, and
secret `FCM_PRIVATE_KEY` from a least-privilege service account allowed to
send Firebase Cloud Messaging messages.
- Shared encrypted token storage: secret `PUSH_DEVICE_ENCRYPTION_KEY`.

The Android release build separately requires the matching Firebase
`android/app/google-services.json`. It is intentionally ignored by Git and must
be supplied from release secrets. Signed release builds fail closed if it is
absent, rather than publishing an APK with nonfunctional notifications.
7 changes: 7 additions & 0 deletions cloudflare/migrations/0003_push_device_deliveries.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS push_device_deliveries (
installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE,
idempotency_key TEXT NOT NULL,
device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE,
delivered_at INTEGER NOT NULL,
PRIMARY KEY (installation_id,idempotency_key,device_id)
);
7 changes: 7 additions & 0 deletions cloudflare/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,10 @@ CREATE TABLE IF NOT EXISTS push_deliveries (
created_at INTEGER NOT NULL,
PRIMARY KEY (installation_id,idempotency_key)
);
CREATE TABLE IF NOT EXISTS push_device_deliveries (
installation_id TEXT NOT NULL REFERENCES push_installations(installation_id) ON DELETE CASCADE,
idempotency_key TEXT NOT NULL,
device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE,
delivered_at INTEGER NOT NULL,
PRIMARY KEY (installation_id,idempotency_key,device_id)
);
70 changes: 65 additions & 5 deletions cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ interface Env {
APNS_TEAM_ID?: string;
APNS_KEY_ID?: string;
APNS_PRIVATE_KEY?: string;
FCM_PROJECT_ID?: string;
FCM_CLIENT_EMAIL?: string;
FCM_PRIVATE_KEY?: string;
}

type WorkspaceRow = {
Expand Down Expand Up @@ -177,6 +180,58 @@ async function sendApns(env: Env, device: PushDeviceRow, notification: Record<st
return { delivered: response.ok, permanent: response.status === 410 || ["BadDeviceToken", "DeviceTokenNotForTopic", "Unregistered"].includes(reason), reason };
}

let fcmAuthorization: { value: string; expires: number } | null = null;
async function fcmAccessToken(env: Env): Promise<string> {
if (!env.FCM_PROJECT_ID || !env.FCM_CLIENT_EMAIL || !env.FCM_PRIVATE_KEY) throw new Error("FCM delivery is not configured.");
const timestamp = Math.floor(Date.now() / 1000);
if (fcmAuthorization && fcmAuthorization.expires - timestamp > 300) return fcmAuthorization.value;
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const claims = base64url(JSON.stringify({
iss: env.FCM_CLIENT_EMAIL,
scope: "https://www.googleapis.com/auth/firebase.messaging",
aud: "https://oauth2.googleapis.com/token",
iat: timestamp,
exp: timestamp + 3600,
}));
const key = await crypto.subtle.importKey("pkcs8", pemBytes(env.FCM_PRIVATE_KEY), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]);
const signature = new Uint8Array(await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(`${header}.${claims}`)));
const assertion = `${header}.${claims}.${base64url(signature)}`;
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
});
const body = await response.json().catch(() => ({})) as { access_token?: string; expires_in?: number; error_description?: string };
if (!response.ok || !body.access_token) throw new Error(body.error_description || `FCM authorization returned HTTP ${response.status}.`);
fcmAuthorization = { value: body.access_token, expires: timestamp + Number(body.expires_in || 3600) };
return body.access_token;
}

async function sendFcm(env: Env, device: PushDeviceRow, notification: Record<string, unknown>): Promise<{ delivered: boolean; permanent: boolean; reason: string }> {
if (!env.PUSH_DEVICE_ENCRYPTION_KEY || !env.FCM_PROJECT_ID) throw new Error("FCM delivery is not configured.");
const token = await unseal(env.PUSH_DEVICE_ENCRYPTION_KEY, device.token_cipher);
const authorization = await fcmAccessToken(env);
const sound = notification.sound === false ? undefined : "default";
const response = await fetch(`https://fcm.googleapis.com/v1/projects/${encodeURIComponent(env.FCM_PROJECT_ID)}/messages:send`, {
method: "POST",
headers: { authorization: `Bearer ${authorization}`, "content-type": "application/json" },
body: JSON.stringify({ message: {
token,
notification: { title: String(notification.title || "1Helm").slice(0, 178), body: String(notification.body || "New activity").slice(0, 512) },
data: {
channelId: String(Number(notification.channelId || 0)),
messageId: String(Number(notification.messageId || 0)),
rootMessageId: String(Number(notification.rootMessageId || 0)),
},
android: { priority: "high", notification: { ...(sound ? { sound } : {}), channel_id: "1helm_activity", tag: String(notification.idempotency_key || `message-${notification.messageId || "new"}`).slice(0, 64) } },
} }),
});
const body = await response.json().catch(() => ({})) as { error?: { message?: string; details?: Array<{ errorCode?: string }> } };
const code = body.error?.details?.find((detail) => detail.errorCode)?.errorCode || "";
const reason = code || body.error?.message || (response.ok ? "" : `HTTP ${response.status}`);
return { delivered: response.ok, permanent: response.status === 404 || ["UNREGISTERED", "SENDER_ID_MISMATCH"].includes(code), reason };
}

async function pushDelivery(request: Request, env: Env): Promise<Response> {
const body = await request.json().catch(() => ({})) as Record<string, unknown>;
const installationId = String(body.installation_id || "");
Expand All @@ -196,15 +251,20 @@ async function pushDelivery(request: Request, env: Env): Promise<Response> {
let delivered = 0;
const errors: string[] = [];
for (const device of devices) {
if (device.platform !== "ios") { errors.push("Android delivery is not configured."); continue; }
const alreadyDelivered = await env.REGISTRY.prepare("SELECT 1 FROM push_device_deliveries WHERE installation_id=? AND idempotency_key=? AND device_id=?").bind(installationId, idempotencyKey, device.id).first();
if (alreadyDelivered) { delivered += 1; continue; }
try {
const outcome = await sendApns(env, device, { ...body, idempotency_key: idempotencyKey });
if (outcome.delivered) delivered += 1;
else if (outcome.reason) errors.push(outcome.reason);
const outcome = device.platform === "ios"
? await sendApns(env, device, { ...body, idempotency_key: idempotencyKey })
: await sendFcm(env, device, { ...body, idempotency_key: idempotencyKey });
if (outcome.delivered) {
delivered += 1;
await env.REGISTRY.prepare("INSERT OR IGNORE INTO push_device_deliveries (installation_id,idempotency_key,device_id,delivered_at) VALUES (?,?,?,?)").bind(installationId, idempotencyKey, device.id, Date.now()).run();
} else if (outcome.reason) errors.push(outcome.reason);
if (outcome.permanent) await env.REGISTRY.prepare("DELETE FROM push_devices WHERE id=?").bind(device.id).run();
} catch (error) { errors.push(error instanceof Error ? error.message : String(error)); }
}
if (devices.length && delivered === 0 && errors.length) {
if (devices.length && errors.length) {
await env.REGISTRY.prepare("DELETE FROM push_deliveries WHERE installation_id=? AND idempotency_key=? AND delivered_count=-1").bind(installationId, idempotencyKey).run();
return json({ error: errors.join("; ").slice(0, 500) }, 502);
}
Expand Down
16 changes: 12 additions & 4 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@ async function startServerMode(window) {
}
}

function appPermissionAllowed(webContents, permission, details = {}) {
const pageUrl = webContents?.getURL?.() || "";
if (!allowedAppUrl(pageUrl)) return false;
if (permission === "notifications") return true;
return microphonePermissionAllowed(webContents, permission, details);
}

function microphonePermissionAllowed(webContents, permission, details = {}) {
const pageUrl = webContents?.getURL?.() || "";
if (permission !== "media" || !allowedAppUrl(pageUrl)) return false;
Expand Down Expand Up @@ -285,6 +292,7 @@ function createWindow(showWhenReady = true) {
if (/^https?:/i.test(url)) openAuthWindow(url);
});
if (showWhenReady) window.once("ready-to-show", () => window.show());
window.on("close", (event) => { if (process.platform === "darwin" && !quitting) { event.preventDefault(); window.hide(); } });
window.on("closed", () => { if (mainWindow === window) mainWindow = null; });
void loadInitialWorkspace(window);
mainWindow = window;
Expand All @@ -302,10 +310,10 @@ if (!app.requestSingleInstanceLock()) {
});

app.whenReady().then(async () => {
session.defaultSession.setPermissionCheckHandler((webContents, permission, _origin, details) => microphonePermissionAllowed(webContents, permission, details));
session.defaultSession.setPermissionCheckHandler((webContents, permission, _origin, details) => appPermissionAllowed(webContents, permission, details));
session.defaultSession.setPermissionRequestHandler(async (webContents, permission, callback, details) => {
if (!microphonePermissionAllowed(webContents, permission, details)) { callback(false); return; }
if (process.platform !== "darwin") { callback(true); return; }
if (!appPermissionAllowed(webContents, permission, details)) { callback(false); return; }
if (permission === "notifications" || process.platform !== "darwin") { callback(true); return; }
try { callback(await systemPreferences.askForMediaAccess("microphone")); }
catch { callback(false); }
});
Expand Down Expand Up @@ -355,7 +363,7 @@ if (!app.requestSingleInstanceLock()) {
}
});

app.on("activate", () => { if (!mainWindow) createWindow(); });
app.on("activate", () => { if (!mainWindow) createWindow(); else { mainWindow.show(); mainWindow.focus(); } });
app.on("window-all-closed", () => {
// On macOS 1Helm remains the native scheduler/fleet manager until Cmd-Q.
if (process.platform !== "darwin") app.quit();
Expand Down
16 changes: 16 additions & 0 deletions docs/VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ sole visual home for recurring work and its chronological run history; workflow
runs do not spill into Chat, Board, or Threads, while each run remains a normal
interactive thread when opened.

## Honest model usage

A thread's primary token indicator answers how much input context the latest model call processed, not how many repeated prompt-token encounters accumulated across an agent loop. 1Helm calculates this itself from the structured messages and tool schemas it sends, using one stable provider-neutral approximation. Cached is the unchanged leading context shared with the preceding call; output counts response text and tool-call payloads received by 1Helm; calls count successful invocations. These product metrics never depend on provider usage reports. Lifetime output and model-call count remain cumulative because they represent newly generated work and actual invocations; cumulative prompt traffic is never presented as the size of a conversation.

## Durable growth

Residents start with a substantive operational arsenal, not a handful of
Expand Down Expand Up @@ -116,3 +120,15 @@ merely for parity. Its core bet is the compounding resident world: one identity,
one private computer, one workspace, and years of accumulated memory, skills,
corrections, artifacts, and obligations—with Skipper automatically handling
every boundary.

## Notifications are a platform-complete delivery contract

“Notifications” means operating-system-visible delivery, not only an unread
badge or an audible chirp. Browser/PWA subscriptions use installation-owned
VAPID keys and a durable local outbox; native iOS and Android registrations use
the encrypted central relay with APNs and FCM respectively; Electron uses the
OS notification surface while its background process is running. Every route
shares account ownership, author exclusion, settled-message deduplication,
channel mute, sound preference, and channel/thread navigation semantics. A
release build must fail rather than silently package Android without its
Firebase application configuration.
Loading
Loading