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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ This changelog tracks notable repository changes. Add new entries to the topmost

- Social Card Toolkit now follows the shared single-active accordion behavior, keeping only one section in focus and routing long controls through active-pane scrolling.
- Data Visualization Toolkit now uses the same single-active accordion behavior as Motion Toolkit, keeping the active section open, removing sibling collapsed pills, and moving scrolling into the active section.
- Added a deterministic iTerm proof-capture helper (`agent/scripts/capture_iterm_proof.mjs`) that refuses active-session fallback, requires explicit selectors, and errors on ambiguous matches so terminal proof screenshots do not target the wrong Codex session.
- Social Card Toolkit now mirrors the same single-active accordion behavior, so only one section stays in focus and long parameter groups scroll inside the active panel.
- Repaired malformed Social Card Toolkit source/test merge artifacts so CI `npm run test:ci` parses and validates the branch again (duplicate state declarations removed and chart-toggle test aligned with single-active accordion behavior).
- iTerm proof-session targeting now rejects non-numeric `--window-id` values with an explicit error instead of silently degrading selector matching.

### Fixed

Expand Down
106 changes: 106 additions & 0 deletions agent/lib/itermSessionTargeting.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
function normalizeText(value) {
return typeof value === 'string' ? value : '';
}

function parseWindowId(value) {
if (value === undefined || value === null || value === '') {
return null;
}

const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed)) {
throw new Error('Invalid --window-id value. Provide a numeric window id.');
}

return parsed;
}

function includesInsensitive(haystack, needle) {
if (!needle) return true;
return normalizeText(haystack).toLowerCase().includes(needle.toLowerCase());
}

export function buildSessionSelectors(options = {}) {
return {
sessionId: options.sessionId ? String(options.sessionId) : null,
windowId: parseWindowId(options.windowId),
titleContains: options.titleContains ? String(options.titleContains) : null,
textContains: options.textContains ? String(options.textContains) : null,
tty: options.tty ? String(options.tty) : null
};
}

export function hasSelector(selectors) {
return Boolean(
selectors.sessionId ||
Number.isFinite(selectors.windowId) ||
selectors.titleContains ||
selectors.textContains ||
selectors.tty
);
}

export function filterSessions(sessions, selectors) {
return sessions.filter((session) => {
if (selectors.sessionId && String(session.sessionId) !== selectors.sessionId) {
return false;
}

if (Number.isFinite(selectors.windowId) && Number(session.windowId) !== selectors.windowId) {
return false;
}

if (selectors.tty && normalizeText(session.tty) !== selectors.tty) {
return false;
}

if (!includesInsensitive(session.title, selectors.titleContains)) {
return false;
}

if (!includesInsensitive(session.textPreview, selectors.textContains)) {
return false;
}

return true;
});
}

export function describeSession(session) {
return `window=${session.windowId} session=${session.sessionId} title=${JSON.stringify(
session.title
)} tty=${JSON.stringify(session.tty)}`;
}

export function selectSession(sessions, options = {}) {
const selectors = buildSessionSelectors(options);

if (!hasSelector(selectors)) {
throw new Error(
'Refusing to guess a target session. Provide one selector: --session-id, --window-id, --title-contains, --text-contains, or --tty.'
);
}

const matches = filterSessions(sessions, selectors);

if (matches.length === 0) {
throw new Error('No iTerm session matched the provided selectors.');
}

if (matches.length > 1) {
const details = matches.map(describeSession).join('\n');
throw new Error(`Ambiguous iTerm session selection. Narrow selectors.\n${details}`);
}

return matches[0];
}

export function normalizeSession(session) {
return {
windowId: Number(session.windowId),
sessionId: String(session.sessionId),
title: normalizeText(session.title),
tty: normalizeText(session.tty),
textPreview: normalizeText(session.textPreview)
};
}
17 changes: 17 additions & 0 deletions agent/memory/workflow_learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,23 @@
4. Check shared component defaults so controlled and uncontrolled paths match the intended UX.
5. Keep browser evidence deterministic with Chrome DevTools MCP when available, or record the skip reason immediately.

## Issue 13: iTerm Proof Session Screenshot Targeting

### What Worked

- Requiring explicit selectors removed the accidental active-session fallback path.
- Keeping session-targeting logic in a small helper module made ambiguity and no-match behavior easy to lock with unit tests.
- A single script that supports both `--list` and capture reduced manual translation between discovery and capture steps.

### What Slowed Us Down

- Runtime validation of iTerm enumeration is environment-dependent and can fail in headless or sandboxed automation contexts.
- GitHub label editing can require scopes that are not necessary for issue listing and commenting.

### Workflow Updates Needed

- For terminal/app workflow bugs, treat selector ambiguity tests as primary validation when desktop app access is unavailable.
- Keep recording explicit screenshot skip reasons in PR and issue notes when environment app access is blocked.
## Weekly Sweep 2026-03-24: Issue 46 Delivery Retry

### What Worked
Expand Down
157 changes: 157 additions & 0 deletions agent/scripts/capture_iterm_proof.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env node

import { execFileSync } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import {
normalizeSession,
selectSession,
describeSession
} from '../lib/itermSessionTargeting.mjs';

const ITERM_SESSIONS_JXA = `
const app = Application('iTerm');
const sessions = [];
for (const window of app.windows()) {
const windowId = Number(window.id());
for (const tab of window.tabs()) {
for (const session of tab.sessions()) {
const fullText = String(session.contents() || '');
sessions.push({
windowId,
sessionId: String(session.id()),
title: String(session.name() || ''),
tty: String(session.tty() || ''),
textPreview: fullText.slice(-600)
});
}
}
}
JSON.stringify(sessions);
`;

function parseArgs(argv) {
const options = {
list: false,
output: '',
sessionId: null,
windowId: null,
titleContains: null,
textContains: null,
tty: null
};

for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];

switch (argument) {
case '--list':
options.list = true;
break;
case '--output':
options.output = argv[index + 1] ?? '';
index += 1;
break;
case '--session-id':
options.sessionId = argv[index + 1] ?? null;
index += 1;
break;
case '--window-id':
options.windowId = argv[index + 1] ?? null;
index += 1;
break;
case '--title-contains':
options.titleContains = argv[index + 1] ?? null;
index += 1;
break;
case '--text-contains':
options.textContains = argv[index + 1] ?? null;
index += 1;
break;
case '--tty':
options.tty = argv[index + 1] ?? null;
index += 1;
break;
case '--help':
case '-h':
options.help = true;
break;
default:
break;
}
}

return options;
}

function printUsage() {
process.stdout.write(`Usage:\n`);
process.stdout.write(` node agent/scripts/capture_iterm_proof.mjs --list\n`);
process.stdout.write(
` node agent/scripts/capture_iterm_proof.mjs --output <path> [--session-id <id> | --window-id <id>] [--title-contains <text>] [--text-contains <text>] [--tty <tty>]\n`
);
}

function fetchSessions() {
const output = execFileSync('osascript', ['-l', 'JavaScript', '-e', ITERM_SESSIONS_JXA], {
encoding: 'utf8'
});

const parsed = JSON.parse(output);
if (!Array.isArray(parsed)) return [];
return parsed.map(normalizeSession);
}

function ensureParentDirectory(filePath) {
const absolutePath = path.resolve(filePath);
mkdirSync(path.dirname(absolutePath), { recursive: true });
return absolutePath;
}

function captureWindow(windowId, outputPath) {
execFileSync('screencapture', ['-x', '-l', String(windowId), outputPath], { stdio: 'inherit' });
}

function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printUsage();
return;
}

try {
const sessions = fetchSessions();

if (options.list) {
process.stdout.write(`${JSON.stringify(sessions, null, 2)}\n`);
return;
}

if (!options.output) {
throw new Error('Missing required --output <path>.');
}

const target = selectSession(sessions, options);
const absoluteOutputPath = ensureParentDirectory(options.output);
captureWindow(target.windowId, absoluteOutputPath);

process.stdout.write(
`${JSON.stringify(
{
outputPath: absoluteOutputPath,
selectedSession: target,
selectedSummary: describeSession(target)
},
null,
2
)}\n`
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`capture_iterm_proof failed: ${message}\n`);
process.exitCode = 1;
}
}

main();
10 changes: 0 additions & 10 deletions apps/social-card-toolkit/src/SocialCardToolkitPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,6 @@ export function SocialCardToolkitPage() {
const [draft, setDraft] = useState<SocialCardDraft>(getDefaultSocialCardDraft);
const [activeSection, setActiveSection] = useState<SocialCardSectionId | null>(null);
const [presetName, setPresetName] = useState(DIOSCURI_AGENT_TEAM_ANNOUNCEMENT_PRESET.name);
const [activeSection, setActiveSection] = useState<
'template-output' | 'copy' | 'chart' | 'saved-presets' | null
>(null);
const [activeSection, setActiveSection] = useState<SocialSectionId | null>(null);
const [presets, setPresets] = useState<SocialCardPreset[]>(() =>
loadStoredValue<SocialCardPreset[]>(STORAGE_KEY, SEEDED_SOCIAL_CARD_PRESETS).map((preset) =>
normalizeSocialPreset(preset)
Expand Down Expand Up @@ -90,10 +86,6 @@ export function SocialCardToolkitPage() {
downloadBlob(blob, `${presetName || 'social-card'}.png`);
};

function handleSectionChange(
sectionId: 'template-output' | 'copy' | 'chart' | 'saved-presets',
open: boolean
) {
function handleSectionChange(sectionId: SocialCardSectionId, open: boolean) {
setActiveSection(open ? sectionId : null);
}
Expand Down Expand Up @@ -335,8 +327,6 @@ export function SocialCardToolkitPage() {
);
}

type SocialSectionId = 'template-output' | 'copy' | 'chart' | 'saved-presets';

function Field({
label,
value,
Expand Down
Loading
Loading